From 653b20030aea4245ff2fdc241a30acb414323981 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Wed, 8 Jul 2026 13:46:32 -0500 Subject: [PATCH 1/6] Clean warnings --- crates/bindings-csharp/BSATN.Codegen/Type.cs | 12 +-- .../BSATN.Runtime/BSATN/Runtime.cs | 8 +- .../BSATN.Runtime/BSATN/U128.cs | 4 +- .../bindings-csharp/BSATN.Runtime/Builtins.cs | 38 +++---- .../BSATN.Runtime/QueryBuilder.cs | 53 ++-------- crates/bindings-csharp/Codegen.Tests/Tests.cs | 15 +-- crates/bindings-csharp/Codegen/Module.cs | 98 +++++++++---------- crates/bindings-csharp/Directory.Build.props | 6 -- crates/bindings-csharp/Runtime/Http.cs | 24 ++--- .../Runtime/Internal/Bounds.cs | 5 +- .../bindings-csharp/Runtime/Internal/FFI.cs | 2 +- .../Runtime/Internal/ITable.cs | 2 + .../Runtime/Internal/Module.cs | 14 +-- crates/bindings-csharp/Runtime/JwtClaims.cs | 12 ++- crates/bindings-csharp/Runtime/Router.cs | 4 +- .../Runtime/TransactionalContextState.cs | 6 +- 16 files changed, 126 insertions(+), 177 deletions(-) diff --git a/crates/bindings-csharp/BSATN.Codegen/Type.cs b/crates/bindings-csharp/BSATN.Codegen/Type.cs index c639b6db8ae..537c1c9f2dc 100644 --- a/crates/bindings-csharp/BSATN.Codegen/Type.cs +++ b/crates/bindings-csharp/BSATN.Codegen/Type.cs @@ -740,13 +740,13 @@ public override string ToString() => write = "value.WriteFields(writer);"; - var declHashName = (MemberDeclaration decl) => $"___hash{decl.Name}"; + static string DeclHashName(MemberDeclaration decl) => $"___hash{decl.Name}"; getHashCode = $$""" - {{string.Join("\n", bsatnDecls.Select(decl => decl.Type.GetHashCodeStatement(decl.Identifier, declHashName(decl))))}} + {{string.Join("\n", bsatnDecls.Select(decl => decl.Type.GetHashCodeStatement(decl.Identifier, DeclHashName(decl))))}} return {{JoinOrValue( " ^\n ", - bsatnDecls.Select(declHashName), + bsatnDecls.Select(DeclHashName), "0" // if there are no members, the hash is 0. )}}; """; @@ -789,7 +789,7 @@ public override int GetHashCode() // If we are a reference type, various equality methods need to take nullable references. // If we are a value type, everything is pleasantly by-value. var fullNameMaybeRef = $"{FullName}{(Scope.IsStruct ? "" : "?")}"; - var declEqualsName = (MemberDeclaration decl) => $"___eq{decl.Name}"; + static string DeclEqualsName(MemberDeclaration decl) => $"___eq{decl.Name}"; extensions.Contents.Append( $$""" @@ -798,10 +798,10 @@ public override int GetHashCode() public bool Equals({{fullNameMaybeRef}} that) { {{(Scope.IsStruct ? "" : "if (((object?)that) == null) { return false; }\n ")}} - {{string.Join("\n", bsatnDecls.Select(decl => decl.Type.EqualsStatement($"this.{decl.Identifier}", $"that.{decl.Identifier}", declEqualsName(decl))))}} + {{string.Join("\n", bsatnDecls.Select(decl => decl.Type.EqualsStatement($"this.{decl.Identifier}", $"that.{decl.Identifier}", DeclEqualsName(decl))))}} return {{JoinOrValue( " &&\n ", - bsatnDecls.Select(declEqualsName), + bsatnDecls.Select(DeclEqualsName), "true" // if there are no elements, the structs are equal :) )}}; } diff --git a/crates/bindings-csharp/BSATN.Runtime/BSATN/Runtime.cs b/crates/bindings-csharp/BSATN.Runtime/BSATN/Runtime.cs index eeba596929b..badbb277c92 100644 --- a/crates/bindings-csharp/BSATN.Runtime/BSATN/Runtime.cs +++ b/crates/bindings-csharp/BSATN.Runtime/BSATN/Runtime.cs @@ -129,7 +129,9 @@ public interface IReadWrite /// Note: the [Type] macro rejects enums with explicitly set values (see Codegen.Tests), /// so this array is guaranteed to be continuous and indexed starting from 0. /// +#pragma warning disable CA2263, IDE0305 // netstandard2.1 lacks the generic Enum overloads. private static readonly T[] TagToValue = Enum.GetValues(typeof(T)).Cast().ToArray(); +#pragma warning restore CA2263, IDE0305 public T Read(BinaryReader reader) { @@ -177,9 +179,9 @@ public AlgebraicType GetAlgebraicType(ITypeRegistrar registrar) => registrar.RegisterType( (_) => new AlgebraicType.Sum( - Enum.GetNames(typeof(T)) - .Select(name => new AggregateElement(name, AlgebraicType.Unit)) - .ToArray() +#pragma warning disable CA2263 // netstandard2.1 lacks the generic Enum overloads. + [.. Enum.GetNames(typeof(T)).Select(name => new AggregateElement(name, AlgebraicType.Unit))] +#pragma warning restore CA2263 ) ); } diff --git a/crates/bindings-csharp/BSATN.Runtime/BSATN/U128.cs b/crates/bindings-csharp/BSATN.Runtime/BSATN/U128.cs index 936e4756d1b..5158d9fc213 100644 --- a/crates/bindings-csharp/BSATN.Runtime/BSATN/U128.cs +++ b/crates/bindings-csharp/BSATN.Runtime/BSATN/U128.cs @@ -45,8 +45,8 @@ public static U128 FromBytesBigEndian(ReadOnlySpan bytes) ); } - var upper = BinaryPrimitives.ReadUInt64BigEndian(bytes.Slice(0, 8)); - var lower = BinaryPrimitives.ReadUInt64BigEndian(bytes.Slice(8, 8)); + var upper = BinaryPrimitives.ReadUInt64BigEndian(bytes[..8]); + var lower = BinaryPrimitives.ReadUInt64BigEndian(bytes[8..16]); return new U128(upper, lower); } diff --git a/crates/bindings-csharp/BSATN.Runtime/Builtins.cs b/crates/bindings-csharp/BSATN.Runtime/Builtins.cs index e204985b54a..f8cb375f284 100644 --- a/crates/bindings-csharp/BSATN.Runtime/Builtins.cs +++ b/crates/bindings-csharp/BSATN.Runtime/Builtins.cs @@ -357,7 +357,7 @@ public static implicit operator DateTimeOffset(Timestamp t) => DateTimeOffset.UnixEpoch.AddTicks(t.MicrosecondsSinceUnixEpoch * Util.TicksPerMicrosecond); public static implicit operator Timestamp(DateTimeOffset offset) => - new Timestamp(offset.Subtract(DateTimeOffset.UnixEpoch).Ticks / Util.TicksPerMicrosecond); + new(offset.Subtract(DateTimeOffset.UnixEpoch).Ticks / Util.TicksPerMicrosecond); // For backwards-compatibility. public readonly DateTimeOffset ToStd() => this; @@ -373,7 +373,7 @@ public override readonly string ToString() public static readonly Timestamp UNIX_EPOCH = new(0); public static Timestamp FromTimeDurationSinceUnixEpoch(TimeDuration timeDuration) => - new Timestamp(timeDuration.Microseconds); + new(timeDuration.Microseconds); public readonly TimeDuration ToTimeDurationSinceUnixEpoch() => TimeDurationSince(UNIX_EPOCH); @@ -383,13 +383,13 @@ public static Timestamp FromTimeSpanSinceUnixEpoch(TimeSpan timeSpan) => public readonly TimeSpan ToTimeSpanSinceUnixEpoch() => (TimeSpan)ToTimeDurationSinceUnixEpoch(); public readonly TimeDuration TimeDurationSince(Timestamp earlier) => - new TimeDuration(checked(MicrosecondsSinceUnixEpoch - earlier.MicrosecondsSinceUnixEpoch)); + new(checked(MicrosecondsSinceUnixEpoch - earlier.MicrosecondsSinceUnixEpoch)); public static Timestamp operator +(Timestamp point, TimeDuration interval) => - new Timestamp(checked(point.MicrosecondsSinceUnixEpoch + interval.Microseconds)); + new(checked(point.MicrosecondsSinceUnixEpoch + interval.Microseconds)); public static Timestamp operator -(Timestamp point, TimeDuration interval) => - new Timestamp(checked(point.MicrosecondsSinceUnixEpoch - interval.Microseconds)); + new(checked(point.MicrosecondsSinceUnixEpoch - interval.Microseconds)); public readonly int CompareTo(Timestamp that) { @@ -492,10 +492,10 @@ public static implicit operator TimeDuration(TimeSpan timeSpan) => new(timeSpan.Ticks / Util.TicksPerMicrosecond); public static TimeDuration operator +(TimeDuration lhs, TimeDuration rhs) => - new TimeDuration(checked(lhs.Microseconds + rhs.Microseconds)); + new(checked(lhs.Microseconds + rhs.Microseconds)); public static TimeDuration operator -(TimeDuration lhs, TimeDuration rhs) => - new TimeDuration(checked(lhs.Microseconds + rhs.Microseconds)); + new(checked(lhs.Microseconds + rhs.Microseconds)); // For backwards-compatibility. public readonly TimeSpan ToStd() => this; @@ -574,7 +574,7 @@ long microsSinceUnixEpoch // --- auto-generated --- private ScheduleAt() { } - internal enum @enum : byte + internal enum ScheduleAtVariant : byte { Interval, Time, @@ -586,15 +586,15 @@ public sealed record Time(Timestamp Time_) : ScheduleAt; public readonly partial struct BSATN : IReadWrite { - internal static readonly SpacetimeDB.BSATN.Enum<@enum> __enumTag = new(); + internal static readonly SpacetimeDB.BSATN.Enum __enumTag = new(); internal static readonly TimeDuration.BSATN Interval = new(); internal static readonly Timestamp.BSATN Time = new(); public ScheduleAt Read(BinaryReader reader) => __enumTag.Read(reader) switch { - @enum.Interval => new Interval(Interval.Read(reader)), - @enum.Time => new Time(Time.Read(reader)), + ScheduleAtVariant.Interval => new Interval(Interval.Read(reader)), + ScheduleAtVariant.Time => new Time(Time.Read(reader)), _ => throw new InvalidOperationException( "Invalid tag value, this state should be unreachable." ), @@ -605,12 +605,12 @@ public void Write(BinaryWriter writer, ScheduleAt value) switch (value) { case Interval(var inner): - __enumTag.Write(writer, @enum.Interval); + __enumTag.Write(writer, ScheduleAtVariant.Interval); Interval.Write(writer, inner); break; case Time(var inner): - __enumTag.Write(writer, @enum.Time); + __enumTag.Write(writer, ScheduleAtVariant.Time); Time.Write(writer, inner); break; } @@ -682,7 +682,7 @@ public T UnwrapOrElse(Func f) => private Result() { } - internal enum @enum : byte + internal enum ResultVariant : byte { Ok, Err, @@ -702,15 +702,15 @@ private enum Variant : byte where OkRW : struct, IReadWrite where ErrRW : struct, IReadWrite { - private static readonly SpacetimeDB.BSATN.Enum<@enum> __enumTag = new(); + private static readonly SpacetimeDB.BSATN.Enum __enumTag = new(); private static readonly OkRW okRW = new(); private static readonly ErrRW errRW = new(); public Result Read(BinaryReader reader) => __enumTag.Read(reader) switch { - @enum.Ok => new OkR(okRW.Read(reader)), - @enum.Err => new ErrR(errRW.Read(reader)), + ResultVariant.Ok => new OkR(okRW.Read(reader)), + ResultVariant.Err => new ErrR(errRW.Read(reader)), _ => throw new InvalidOperationException(), }; @@ -719,12 +719,12 @@ public void Write(BinaryWriter writer, Result value) switch (value) { case OkR(var v): - __enumTag.Write(writer, @enum.Ok); + __enumTag.Write(writer, ResultVariant.Ok); okRW.Write(writer, v); break; case ErrR(var e): - __enumTag.Write(writer, @enum.Err); + __enumTag.Write(writer, ResultVariant.Err); errRW.Write(writer, e); break; } diff --git a/crates/bindings-csharp/BSATN.Runtime/QueryBuilder.cs b/crates/bindings-csharp/BSATN.Runtime/QueryBuilder.cs index db16bd56cd7..e8d52a084e6 100644 --- a/crates/bindings-csharp/BSATN.Runtime/QueryBuilder.cs +++ b/crates/bindings-csharp/BSATN.Runtime/QueryBuilder.cs @@ -1,5 +1,3 @@ -#nullable enable - namespace SpacetimeDB; using System; @@ -68,14 +66,9 @@ public interface IQuery string ToSql(); } -public readonly struct BoolExpr +public readonly struct BoolExpr(string sql) { - public string Sql { get; } - - public BoolExpr(string sql) - { - Sql = sql; - } + public string Sql { get; } = sql; public BoolExpr And(BoolExpr other) => new($"({Sql} AND {other.Sql})"); @@ -120,18 +113,9 @@ internal IxJoinEq(string leftRefSql, string rightRefSql) } } -public readonly struct Col +public readonly struct Col(string tableName, string columnName) where TValue : notnull { - private readonly string tableName; - private readonly string columnName; - - public Col(string tableName, string columnName) - { - this.tableName = tableName; - this.columnName = columnName; - } - internal string RefSql => $"{SqlFormat.QuoteIdent(tableName)}.{SqlFormat.QuoteIdent(columnName)}"; @@ -162,18 +146,9 @@ public Col(string tableName, string columnName) public override string ToString() => RefSql; } -public readonly struct IxCol +public readonly struct IxCol(string tableName, string columnName) where TValue : notnull { - private readonly string tableName; - private readonly string columnName; - - public IxCol(string tableName, string columnName) - { - this.tableName = tableName; - this.columnName = columnName; - } - internal string RefSql => $"{SqlFormat.QuoteIdent(tableName)}.{SqlFormat.QuoteIdent(columnName)}"; @@ -187,19 +162,9 @@ public IxJoinEq Eq(IxCol other) = public override string ToString() => RefSql; } -public sealed class Table : IQuery +public sealed class Table(string tableName, TCols cols, TIxCols ixCols) + : IQuery { - private readonly string tableName; - private readonly TCols cols; - private readonly TIxCols ixCols; - - public Table(string tableName, TCols cols, TIxCols ixCols) - { - this.tableName = tableName; - this.cols = cols; - this.ixCols = ixCols; - } - internal string TableRefSql => SqlFormat.QuoteIdent(tableName); internal TCols Cols => cols; @@ -229,7 +194,7 @@ public LeftSemiJoin L >( Table right, Func> on - ) => new(this, right, on(ixCols, right.ixCols), whereExpr: null); + ) => new(this, right, on(ixCols, right.IxCols), whereExpr: null); public RightSemiJoin RightSemijoin< TRightRow, @@ -238,7 +203,7 @@ public RightSemiJoin >( Table right, Func> on - ) => new(this, right, on(ixCols, right.ixCols), leftWhereExpr: null); + ) => new(this, right, on(ixCols, right.IxCols), leftWhereExpr: null); } public sealed class FromWhere : IQuery @@ -889,7 +854,7 @@ public static string FormatHexLiteral(string hex) var s = hex; if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) { - s = s.Substring(2); + s = s[2..]; } s = s.Replace("-", string.Empty); diff --git a/crates/bindings-csharp/Codegen.Tests/Tests.cs b/crates/bindings-csharp/Codegen.Tests/Tests.cs index a70a1bd3482..4133819ef9c 100644 --- a/crates/bindings-csharp/Codegen.Tests/Tests.cs +++ b/crates/bindings-csharp/Codegen.Tests/Tests.cs @@ -26,18 +26,11 @@ public static class GeneratorSnapshotTests record struct StepOutput(string Key, IncrementalStepRunReason Reason, object Value); - private class Fixture + private class Fixture(string projectDir, CSharpCompilation sampleCompilation) { - private readonly string projectDir; - public CSharpCompilation SampleCompilation { get; } - public CSharpParseOptions ParseOptions { get; } - - public Fixture(string projectDir, CSharpCompilation sampleCompilation) - { - this.projectDir = projectDir; - SampleCompilation = sampleCompilation; - ParseOptions = (CSharpParseOptions)sampleCompilation.SyntaxTrees.First().Options; - } + public CSharpCompilation SampleCompilation { get; } = sampleCompilation; + public CSharpParseOptions ParseOptions { get; } = + (CSharpParseOptions)sampleCompilation.SyntaxTrees.First().Options; public static async Task Compile(string name) { diff --git a/crates/bindings-csharp/Codegen/Module.cs b/crates/bindings-csharp/Codegen/Module.cs index 2ded9cb3412..3e76b20c6ef 100644 --- a/crates/bindings-csharp/Codegen/Module.cs +++ b/crates/bindings-csharp/Codegen/Module.cs @@ -762,15 +762,15 @@ public sealed class {{{identifierName}}}Index /// Represents a generated accessor for a table, providing different access patterns /// and visibility levels for the underlying table data. /// - /// Name of the generated accessor type - /// Fully qualified name of the table type - /// C# source code for the accessor implementation - /// C# property getter for accessing the accessor + /// Name of the generated accessor type + /// Fully qualified name of the table type + /// C# source code for the accessor implementation + /// C# property getter for accessing the accessor public record struct GeneratedTableAccessor( - string tableAccessorName, - string tableName, - string tableAccessor, - string getter + string TableAccessorName, + string TableName, + string TableAccessor, + string Getter ); /// @@ -862,10 +862,10 @@ v.Scheduled is { } scheduled } public record struct GeneratedReadOnlyAccessor( - string tableAccessorName, - string tableName, - string readOnlyAccessor, - string readOnlyGetter + string TableAccessorName, + string TableName, + string ReadOnlyAccessor, + string ReadOnlyGetter ); public IEnumerable GenerateReadOnlyAccessors() @@ -1023,14 +1023,14 @@ public readonly partial struct QueryBuilder /// /// Represents a default value for a table field, used during table creation. /// - /// Name of the table containing the field - /// Index of the column in the table - /// String representation of the default value + /// Name of the table containing the field + /// Index of the column in the table + /// String representation of the default value /// BSATN Type name of the default value public record struct FieldDefaultValue( - string tableName, - string columnId, - string value, + string TableName, + string ColumnId, + string Value, string BSATNTypeName ); @@ -1059,8 +1059,7 @@ public IEnumerable GenerateDefaultValues() .Select(m => m.Attrs.FirstOrDefault(a => a.Mask == ColumnAttrs.Default)) ); - var withDefaultValues = - fieldsWithDefaultValues as ColumnDeclaration[] ?? fieldsWithDefaultValues.ToArray(); + var withDefaultValues = fieldsWithDefaultValues as ColumnDeclaration[] ?? [.. fieldsWithDefaultValues]; foreach (var fieldsWithDefaultValue in withDefaultValues) { if ( @@ -1722,49 +1721,48 @@ public string GenerateClass() if (HasWrongSignature) { - bodyLines = new[] - { + bodyLines = + [ "throw new System.InvalidOperationException(\"Invalid procedure signature.\");", - }; + ]; } else if (HasTxWrapper) { - var successLines = txPayloadIsUnit - ? new[] { "return System.Array.Empty();" } - : new[] - { + string[] successLines = txPayloadIsUnit + ? ["return System.Array.Empty();"] + : + [ "using var output = new MemoryStream();", "using var writer = new BinaryWriter(output);", "__txReturnRW.Write(writer, outcome.Value!);", "return output.ToArray();", - }; + ]; - bodyLines = new[] - { + bodyLines = + [ $"var outcome = {invocation};", "if (!outcome.IsSuccess)", "{", " throw outcome.Error ?? new System.InvalidOperationException(\"Transaction failed.\");", "}", - } - .Concat(successLines) - .ToArray(); + .. successLines, + ]; } else if (ReturnType.Name == "SpacetimeDB.Unit") { - bodyLines = new[] { $"{invocation};", "return System.Array.Empty();" }; + bodyLines = [$"{invocation};", "return System.Array.Empty();"]; } else { var serializer = $"new {ReturnType.ToBSATNString()}()"; - bodyLines = new[] - { + bodyLines = + [ $"var result = {invocation};", "using var output = new MemoryStream();", "using var writer = new BinaryWriter(output);", $"{serializer}.Write(writer, result);", "return output.ToArray();", - }; + ]; } var invokeBody = string.Join("\n", bodyLines.Select(line => $" {line}")); @@ -2348,8 +2346,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) tables .SelectMany((t, ct) => t.GenerateTableAccessors()) .WithTrackingName("SpacetimeDB.Table.GenerateTableAccessors"), - v => v.tableAccessorName, - v => v.tableName + v => v.TableAccessorName, + v => v.TableName ); var readOnlyAccessors = CollectDistinct( @@ -2358,8 +2356,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) tables .SelectMany((t, ct) => t.GenerateReadOnlyAccessors()) .WithTrackingName("SpacetimeDB.Table.GenerateReadOnlyAccessors"), - v => v.tableAccessorName + "ReadOnly", - v => v.tableName + v => v.TableAccessorName + "ReadOnly", + v => v.TableName ); var rlsFilters = context @@ -2391,8 +2389,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) tables .SelectMany((t, ct) => t.GenerateDefaultValues()) .WithTrackingName("SpacetimeDB.Table.GenerateDefaultValues"), - v => v.tableName + "_" + v.columnId, - v => v.tableName + "_" + v.columnId + v => v.TableName + "_" + v.ColumnId, + v => v.TableName + "_" + v.ColumnId ); var moduleOutputInputs = tableAccessors @@ -2749,7 +2747,7 @@ internal HandlerTxContext(Internal.TxContext inner) : base(inner) {} } public sealed class Local : global::SpacetimeDB.LocalBase { - {{string.Join("\n", tableAccessors.Select(v => v.getter))}} + {{string.Join("\n", tableAccessors.Select(v => v.Getter))}} } public sealed record ViewContext : DbContext, Internal.IViewContext @@ -2775,7 +2773,7 @@ internal AnonymousViewContext(Internal.LocalReadOnly db) } namespace SpacetimeDB.Internal.TableHandles { - {{string.Join("\n", tableAccessors.Select(v => v.tableAccessor))}} + {{string.Join("\n", tableAccessors.Select(v => v.TableAccessor))}} } {{string.Join("\n", @@ -2788,12 +2786,12 @@ namespace SpacetimeDB.Internal.TableHandles { )}} namespace SpacetimeDB.Internal.ViewHandles { - {{string.Join("\n", readOnlyAccessors.Array.Select(v => v.readOnlyAccessor))}} + {{string.Join("\n", readOnlyAccessors.Array.Select(v => v.ReadOnlyAccessor))}} } namespace SpacetimeDB.Internal { public sealed partial class LocalReadOnly { - {{string.Join("\n", readOnlyAccessors.Select(v => v.readOnlyGetter))}} + {{string.Join("\n", readOnlyAccessors.Select(v => v.ReadOnlyGetter))}} } } @@ -2865,7 +2863,7 @@ public static void Main() { {{string.Join( "\n", - tableAccessors.Select(t => $"SpacetimeDB.Internal.Module.RegisterTable<{t.tableName}, SpacetimeDB.Internal.TableHandles.{EscapeIdentifier(t.tableAccessorName)}>();") + tableAccessors.Select(t => $"SpacetimeDB.Internal.Module.RegisterTable<{t.TableName}, SpacetimeDB.Internal.TableHandles.{EscapeIdentifier(t.TableAccessorName)}>();") )}} {{( httpRouters.Array.FirstOrDefault(r => r.IsValid) is { } router @@ -2883,9 +2881,9 @@ public static void Main() { + $"var value = new {d.BSATNTypeName}();\n" + "__memoryStream.Position = 0;\n" + "__memoryStream.SetLength(0);\n" - + $"value.Write(__writer, {d.value});\n" + + $"value.Write(__writer, {d.Value});\n" + "var array = __memoryStream.ToArray();\n" - + $"SpacetimeDB.Internal.Module.RegisterTableDefaultValue(\"{d.tableName}\", {d.columnId}, array);" + + $"SpacetimeDB.Internal.Module.RegisterTableDefaultValue(\"{d.TableName}\", {d.ColumnId}, array);" + "\n}\n") )}} } diff --git a/crates/bindings-csharp/Directory.Build.props b/crates/bindings-csharp/Directory.Build.props index 0b47ecdad6b..42d61d11810 100644 --- a/crates/bindings-csharp/Directory.Build.props +++ b/crates/bindings-csharp/Directory.Build.props @@ -25,12 +25,6 @@ true $(NoWarn);CS1591;CS1574 - - $(WarningsNotAsErrors); - CA1822;CA2263; - IDE0005;IDE0028;IDE0039;IDE0057;IDE0065;IDE0078;IDE0090; - IDE0240;IDE0290;IDE0300;IDE0301;IDE0305;IDE0306;IDE1006 - diff --git a/crates/bindings-csharp/Runtime/Http.cs b/crates/bindings-csharp/Runtime/Http.cs index be98b704fcc..8dfbe262e76 100644 --- a/crates/bindings-csharp/Runtime/Http.cs +++ b/crates/bindings-csharp/Runtime/Http.cs @@ -1,6 +1,7 @@ namespace SpacetimeDB; using System; +using System.Diagnostics.CodeAnalysis; using System.Collections.Generic; using System.IO; using System.Linq; @@ -57,7 +58,7 @@ public HttpHeader(string name, string value) /// public readonly record struct HttpBody(byte[] Bytes) { - public static HttpBody Empty => new(Array.Empty()); + public static HttpBody Empty => new([]); public byte[] ToBytes() => Bytes; @@ -82,7 +83,7 @@ public sealed class HttpRequest public HttpMethod Method { get; init; } = HttpMethod.Get; /// HTTP headers to include with the request. - public List Headers { get; init; } = new(); + public List Headers { get; init; } = []; /// Request body bytes. public HttpBody Body { get; init; } = HttpBody.Empty; @@ -271,6 +272,7 @@ public Result Get(string uri, TimeSpan? timeout = null) /// } /// /// + [SuppressMessage("Performance", "CA1822", Justification = "Public instance API exposed through ProcedureContext.Http.")] public Result Send(HttpRequest request) { // The host syscall expects BSATN-encoded spacetimedb_lib::http::Request bytes. @@ -306,10 +308,7 @@ public Result Send(HttpRequest request) var requestWire = new HttpRequestWire { Method = ToWireMethod(request.Method), - Headers = new HttpHeadersWire - { - Entries = request.Headers.Select(ToWireHeader).ToArray(), - }, + Headers = new HttpHeadersWire { Entries = [.. request.Headers.Select(ToWireHeader)] }, Timeout = timeout is null ? null : new HttpTimeoutWire { Timeout = (TimeDuration)timeout.Value }, @@ -423,9 +422,7 @@ internal static HttpRequest FromWire(HttpRequestWire requestWire, byte[] body) = { Uri = requestWire.Uri, Method = FromWireMethod(requestWire.Method), - Headers = requestWire - .Headers.Entries.Select(h => new HttpHeader(h.Name, h.Value, false)) - .ToList(), + Headers = [.. requestWire.Headers.Entries.Select(h => new HttpHeader(h.Name, h.Value, false))], Body = new HttpBody(body), Version = FromWireVersion(requestWire.Version), }; @@ -434,10 +431,7 @@ internal static (HttpResponseWire Response, byte[] Body) ToWire(HttpResponse res ( new HttpResponseWire { - Headers = new HttpHeadersWire - { - Entries = response.Headers.Select(ToWireHeader).ToArray(), - }, + Headers = new HttpHeadersWire { Entries = [.. response.Headers.Select(ToWireHeader)] }, Version = ToWireVersion(response.Version), Code = response.StatusCode, }, @@ -479,9 +473,7 @@ List headers { var version = FromWireVersion(responseWire.Version); - var headers = responseWire - .Headers.Entries.Select(h => new HttpHeader(h.Name, h.Value, false)) - .ToList(); + var headers = responseWire.Headers.Entries.Select(h => new HttpHeader(h.Name, h.Value, false)).ToList(); return (responseWire.Code, version, headers); } diff --git a/crates/bindings-csharp/Runtime/Internal/Bounds.cs b/crates/bindings-csharp/Runtime/Internal/Bounds.cs index 2bdd42c3aa1..f3e302aa8de 100644 --- a/crates/bindings-csharp/Runtime/Internal/Bounds.cs +++ b/crates/bindings-csharp/Runtime/Internal/Bounds.cs @@ -1,6 +1,3 @@ -using System.IO; -using SpacetimeDB.BSATN; - namespace SpacetimeDB { public readonly struct Bound(T min, T max) @@ -16,6 +13,8 @@ public readonly struct Bound(T min, T max) namespace SpacetimeDB.Internal { + using SpacetimeDB.BSATN; + enum BoundVariant : byte { Inclusive, diff --git a/crates/bindings-csharp/Runtime/Internal/FFI.cs b/crates/bindings-csharp/Runtime/Internal/FFI.cs index aa2ebf6f3be..c9f07994f1d 100644 --- a/crates/bindings-csharp/Runtime/Internal/FFI.cs +++ b/crates/bindings-csharp/Runtime/Internal/FFI.cs @@ -369,7 +369,7 @@ private ConsoleTimerId(uint id) )] internal static class ConsoleTimerIdMarshaller { - public static ConsoleTimerId ConvertToManaged(uint id) => new ConsoleTimerId(id); + public static ConsoleTimerId ConvertToManaged(uint id) => new(id); public static uint ConvertToUnmanaged(ConsoleTimerId id) => id.timer_id; } diff --git a/crates/bindings-csharp/Runtime/Internal/ITable.cs b/crates/bindings-csharp/Runtime/Internal/ITable.cs index 5294ce0e7fe..a322216a1d3 100644 --- a/crates/bindings-csharp/Runtime/Internal/ITable.cs +++ b/crates/bindings-csharp/Runtime/Internal/ITable.cs @@ -146,7 +146,9 @@ protected override void IterStart(out FFI.RowIter handle) => return out_; }); +#pragma warning disable IDE1006 // Used by static interface member call sites. internal static FFI.TableId tableId => tableId_.Value; +#pragma warning restore IDE1006 ulong Count { get; } diff --git a/crates/bindings-csharp/Runtime/Internal/Module.cs b/crates/bindings-csharp/Runtime/Internal/Module.cs index 4d15f0e93cc..ad5c8324381 100644 --- a/crates/bindings-csharp/Runtime/Internal/Module.cs +++ b/crates/bindings-csharp/Runtime/Internal/Module.cs @@ -31,11 +31,11 @@ partial class RawModuleDefV10 // Fix it up to a different mangling scheme if it causes problems. private static string GetFriendlyName(Type type) => type.IsGenericType - ? $"{type.Name.Remove(type.Name.IndexOf('`'))}_{string.Join("_", type.GetGenericArguments().Select(GetFriendlyName))}" + ? $"{type.Name[..type.Name.IndexOf('`')]}_{string.Join("_", type.GetGenericArguments().Select(GetFriendlyName))}" : type.Name; private static RawScopedTypeNameV10 MakeScopedTypeName(Type type) => - new(new List(), GetFriendlyName(type)); + new([], GetFriendlyName(type)); internal AlgebraicType.Ref RegisterType(Func makeType) { @@ -84,7 +84,7 @@ internal void RegisterTable(RawTableDefV10 table, RawScheduleDefV10? schedule) internal void RegisterView(RawViewDefV10 view) => viewDefs.Add(view); internal void RegisterViewPrimaryKey(string viewSourceName, IEnumerable columns) => - viewPrimaryKeyDefs.Add(new RawViewPrimaryKeyDefV10(viewSourceName, columns.ToList())); + viewPrimaryKeyDefs.Add(new RawViewPrimaryKeyDefV10(viewSourceName, [.. columns])); internal void RegisterRowLevelSecurity(RawRowLevelSecurityDefV9 rls) => rowLevelSecurityDefs.Add(rls); @@ -96,7 +96,7 @@ internal void RegisterTableDefaultValue(string table, ushort colId, byte[] value defaults = []; defaultValuesByTable.Add(table, defaults); } - defaults.Add(new RawColumnDefaultValueV10(colId, new List(value))); + defaults.Add(new RawColumnDefaultValueV10(colId, [.. value])); } internal void SetCaseConversionPolicy(SpacetimeDB.CaseConversionPolicy policy) => @@ -131,7 +131,7 @@ internal RawModuleDefV10 BuildModuleDefinition() TableAccess: table.TableAccess, DefaultValues: defaults is null ? [] - : new List(defaults), + : [.. defaults], IsEvent: table.IsEvent ) ); @@ -212,7 +212,7 @@ internal RawModuleDefV10 BuildModuleDefinition() { sections.Add( new RawModuleDefV10Section.ExplicitNames( - new ExplicitNames(new List(explicitNames)) + new ExplicitNames([.. explicitNames]) ) ); } @@ -244,7 +244,9 @@ private static void EnsureNativeAotTypeRoots() // These constructions are never executed at runtime — they exist solely // to make the IL scanner compute vtables for TaggedEnum subtypes. // The condition is always false but the scanner must assume it could be true. +#pragma warning disable IDE0078 // Keep this opaque to the NativeAOT IL scanner. if (Environment.TickCount < 0 && Environment.TickCount > 0) +#pragma warning restore IDE0078 { _ = new RawIndexAlgorithm.BTree(null!); _ = new RawConstraintDataV9.Unique(null!); diff --git a/crates/bindings-csharp/Runtime/JwtClaims.cs b/crates/bindings-csharp/Runtime/JwtClaims.cs index 6e656cca202..3ca3e11e029 100644 --- a/crates/bindings-csharp/Runtime/JwtClaims.cs +++ b/crates/bindings-csharp/Runtime/JwtClaims.cs @@ -72,11 +72,13 @@ private List ExtractAudience() return aud.ValueKind switch { - JsonValueKind.String => new List { aud.GetString()! }, - JsonValueKind.Array => aud.EnumerateArray() - .Where(e => e.ValueKind == JsonValueKind.String) - .Select(e => e.GetString()!) - .ToList(), + JsonValueKind.String => [aud.GetString()!], + JsonValueKind.Array => + [ + .. aud.EnumerateArray() + .Where(e => e.ValueKind == JsonValueKind.String) + .Select(e => e.GetString()!), + ], _ => throw new InvalidOperationException("Unexpected type for 'aud' claim in JWT"), }; } diff --git a/crates/bindings-csharp/Runtime/Router.cs b/crates/bindings-csharp/Runtime/Router.cs index e146bd5057d..7377d7b2561 100644 --- a/crates/bindings-csharp/Runtime/Router.cs +++ b/crates/bindings-csharp/Runtime/Router.cs @@ -91,7 +91,7 @@ private Router AddRoute(MethodOrAny method, string path, Handler handler) return new Router(merged); } - private List CloneRoutes() => new(routes); + private List CloneRoutes() => [.. routes]; private static void AddRoute( List routes, @@ -160,5 +160,5 @@ private static void AssertValidPath(string path) } private static bool CharacterIsAcceptableForRoutePath(char c) => - (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c is '-' or '_' or '~' or '/'; + c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '-' or '_' or '~' or '/'; } diff --git a/crates/bindings-csharp/Runtime/TransactionalContextState.cs b/crates/bindings-csharp/Runtime/TransactionalContextState.cs index ef0d72de410..bda7ff23f55 100644 --- a/crates/bindings-csharp/Runtime/TransactionalContextState.cs +++ b/crates/bindings-csharp/Runtime/TransactionalContextState.cs @@ -79,14 +79,14 @@ Func> body } } - private long StartMutTx() + private static long StartMutTx() { var status = FFI.procedure_start_mut_tx(out var micros); FFI.ErrnoHelpers.ThrowIfError(status); return micros; } - private void CommitMutTx() + private static void CommitMutTx() { var status = FFI.procedure_commit_mut_tx(); FFI.ErrnoHelpers.ThrowIfError(status); @@ -98,7 +98,7 @@ private void AbortMutTx() FFI.ErrnoHelpers.ThrowIfError(status); } - private bool CommitMutTxWithRetry(Func retryBody) + private static bool CommitMutTxWithRetry(Func retryBody) { try { From 21481b909a1ce0e47a6532ad82625ade3f9f6d90 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Wed, 8 Jul 2026 13:51:50 -0500 Subject: [PATCH 2/6] Small cleanup from #5271 --- .github/workflows/ci.yml | 6 ++++-- crates/bindings-csharp/Runtime/TransactionalContextState.cs | 2 +- sdks/csharp/src/SpacetimeDBClient.cs | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d2513904f1..038334613ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1047,7 +1047,8 @@ jobs: run: dotnet restore --configfile ../../../NuGet.Config blackholio.csproj - name: Build Godot project - run: godot --headless --verbose --path demo/Blackholio/client-godot --build-solutions --quit + working-directory: demo/Blackholio/client-godot + run: godot --headless --verbose --build-solutions --quit - name: Start SpacetimeDB run: | @@ -1061,7 +1062,8 @@ jobs: bash ./publish.sh - name: Run Godot tests - run: godot --headless --path demo/Blackholio/client-godot --scene res://tests/GodotPlayModeTests.tscn + working-directory: demo/Blackholio/client-godot + run: godot --headless --scene res://tests/GodotPlayModeTests.tscn csharp-testsuite: needs: [merge_queue_noop, lints] diff --git a/crates/bindings-csharp/Runtime/TransactionalContextState.cs b/crates/bindings-csharp/Runtime/TransactionalContextState.cs index bda7ff23f55..9fbdaebaa70 100644 --- a/crates/bindings-csharp/Runtime/TransactionalContextState.cs +++ b/crates/bindings-csharp/Runtime/TransactionalContextState.cs @@ -92,7 +92,7 @@ private static void CommitMutTx() FFI.ErrnoHelpers.ThrowIfError(status); } - private void AbortMutTx() + private static void AbortMutTx() { var status = FFI.procedure_abort_mut_tx(); FFI.ErrnoHelpers.ThrowIfError(status); diff --git a/sdks/csharp/src/SpacetimeDBClient.cs b/sdks/csharp/src/SpacetimeDBClient.cs index ef202ed168b..34645cd60b0 100644 --- a/sdks/csharp/src/SpacetimeDBClient.cs +++ b/sdks/csharp/src/SpacetimeDBClient.cs @@ -1,5 +1,4 @@ using System; -using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; @@ -133,6 +132,8 @@ public abstract class DbConnectionBase : IDbConne where DbConnection : DbConnectionBase, new() where Tables : RemoteTablesBase { + internal const bool IsTesting = false; + public static DbConnectionBuilder Builder() => new(); internal event Action? onConnect; @@ -281,7 +282,6 @@ internal struct ParsedMessage private readonly BlockingCollection _applyQueue = new(new ConcurrentQueue()); - internal static bool IsTesting; internal bool HasMessageToApply => _applyQueue.Count > 0; private readonly CancellationTokenSource _parseCancellationTokenSource = new(); From b4424162a13fb7e065cd575b0a66b430f676f82b Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Mon, 13 Jul 2026 11:11:22 -0500 Subject: [PATCH 3/6] Clean CommitMutTxWithRetry method --- .../Runtime/TransactionalContextState.cs | 45 +++++++------------ 1 file changed, 16 insertions(+), 29 deletions(-) diff --git a/crates/bindings-csharp/Runtime/TransactionalContextState.cs b/crates/bindings-csharp/Runtime/TransactionalContextState.cs index 9fbdaebaa70..b6734b83875 100644 --- a/crates/bindings-csharp/Runtime/TransactionalContextState.cs +++ b/crates/bindings-csharp/Runtime/TransactionalContextState.cs @@ -98,29 +98,6 @@ private static void AbortMutTx() FFI.ErrnoHelpers.ThrowIfError(status); } - private static bool CommitMutTxWithRetry(Func retryBody) - { - try - { - CommitMutTx(); - return true; - } - catch (TransactionNotAnonymousException) - { - return false; - } - catch (StdbException) - { - Log.Warn("Committing anonymous transaction failed; retrying once."); - if (retryBody()) - { - CommitMutTx(); - return true; - } - return false; - } - } - private Result RunWithRetry( Func> body ) @@ -132,18 +109,28 @@ Func> body return result; } - bool Retry() + try { - result = RunOnce(body); - return result is Result.OkR; + CommitMutTx(); + return result; } - - if (!CommitMutTxWithRetry(Retry)) + catch (TransactionNotAnonymousException) { return result; } + catch (StdbException) + { + Log.Warn("Committing anonymous transaction failed; retrying once."); - return result; + var retryResult = RunOnce(body); + if (retryResult is Result.ErrR) + { + return retryResult; + } + + CommitMutTx(); + return retryResult; + } } private Result RunOnce( From 1cf81473ac4cd28addac2f58c4f9357b72ba4b6d Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Fri, 17 Jul 2026 09:13:38 -0500 Subject: [PATCH 4/6] Make IsTesting a property again --- sdks/csharp/src/SpacetimeDBClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdks/csharp/src/SpacetimeDBClient.cs b/sdks/csharp/src/SpacetimeDBClient.cs index 34645cd60b0..6df6c6155e8 100644 --- a/sdks/csharp/src/SpacetimeDBClient.cs +++ b/sdks/csharp/src/SpacetimeDBClient.cs @@ -132,7 +132,7 @@ public abstract class DbConnectionBase : IDbConne where DbConnection : DbConnectionBase, new() where Tables : RemoteTablesBase { - internal const bool IsTesting = false; + internal static bool IsTesting { get; set; } = false; public static DbConnectionBuilder Builder() => new(); From a491ccf8ae853f85256d10c9b1fd93b232432b43 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Fri, 17 Jul 2026 14:42:16 -0500 Subject: [PATCH 5/6] Fix lint --- .../BSATN.Runtime/BSATN/Runtime.cs | 5 +++- crates/bindings-csharp/Codegen/Module.cs | 3 ++- crates/bindings-csharp/Runtime/Http.cs | 27 ++++++++++++++----- .../Runtime/Internal/Module.cs | 8 ++---- 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/crates/bindings-csharp/BSATN.Runtime/BSATN/Runtime.cs b/crates/bindings-csharp/BSATN.Runtime/BSATN/Runtime.cs index badbb277c92..e7519e22765 100644 --- a/crates/bindings-csharp/BSATN.Runtime/BSATN/Runtime.cs +++ b/crates/bindings-csharp/BSATN.Runtime/BSATN/Runtime.cs @@ -180,7 +180,10 @@ public AlgebraicType GetAlgebraicType(ITypeRegistrar registrar) => (_) => new AlgebraicType.Sum( #pragma warning disable CA2263 // netstandard2.1 lacks the generic Enum overloads. - [.. Enum.GetNames(typeof(T)).Select(name => new AggregateElement(name, AlgebraicType.Unit))] + [ + .. Enum.GetNames(typeof(T)) + .Select(name => new AggregateElement(name, AlgebraicType.Unit)), + ] #pragma warning restore CA2263 ) ); diff --git a/crates/bindings-csharp/Codegen/Module.cs b/crates/bindings-csharp/Codegen/Module.cs index 3e76b20c6ef..34040525b94 100644 --- a/crates/bindings-csharp/Codegen/Module.cs +++ b/crates/bindings-csharp/Codegen/Module.cs @@ -1059,7 +1059,8 @@ public IEnumerable GenerateDefaultValues() .Select(m => m.Attrs.FirstOrDefault(a => a.Mask == ColumnAttrs.Default)) ); - var withDefaultValues = fieldsWithDefaultValues as ColumnDeclaration[] ?? [.. fieldsWithDefaultValues]; + var withDefaultValues = + fieldsWithDefaultValues as ColumnDeclaration[] ?? [.. fieldsWithDefaultValues]; foreach (var fieldsWithDefaultValue in withDefaultValues) { if ( diff --git a/crates/bindings-csharp/Runtime/Http.cs b/crates/bindings-csharp/Runtime/Http.cs index 8dfbe262e76..2567f4a11e5 100644 --- a/crates/bindings-csharp/Runtime/Http.cs +++ b/crates/bindings-csharp/Runtime/Http.cs @@ -1,8 +1,8 @@ namespace SpacetimeDB; using System; -using System.Diagnostics.CodeAnalysis; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Text; @@ -272,7 +272,11 @@ public Result Get(string uri, TimeSpan? timeout = null) /// } /// /// - [SuppressMessage("Performance", "CA1822", Justification = "Public instance API exposed through ProcedureContext.Http.")] + [SuppressMessage( + "Performance", + "CA1822", + Justification = "Public instance API exposed through ProcedureContext.Http." + )] public Result Send(HttpRequest request) { // The host syscall expects BSATN-encoded spacetimedb_lib::http::Request bytes. @@ -308,7 +312,10 @@ public Result Send(HttpRequest request) var requestWire = new HttpRequestWire { Method = ToWireMethod(request.Method), - Headers = new HttpHeadersWire { Entries = [.. request.Headers.Select(ToWireHeader)] }, + Headers = new HttpHeadersWire + { + Entries = [.. request.Headers.Select(ToWireHeader)], + }, Timeout = timeout is null ? null : new HttpTimeoutWire { Timeout = (TimeDuration)timeout.Value }, @@ -422,7 +429,10 @@ internal static HttpRequest FromWire(HttpRequestWire requestWire, byte[] body) = { Uri = requestWire.Uri, Method = FromWireMethod(requestWire.Method), - Headers = [.. requestWire.Headers.Entries.Select(h => new HttpHeader(h.Name, h.Value, false))], + Headers = + [ + .. requestWire.Headers.Entries.Select(h => new HttpHeader(h.Name, h.Value, false)), + ], Body = new HttpBody(body), Version = FromWireVersion(requestWire.Version), }; @@ -431,7 +441,10 @@ internal static (HttpResponseWire Response, byte[] Body) ToWire(HttpResponse res ( new HttpResponseWire { - Headers = new HttpHeadersWire { Entries = [.. response.Headers.Select(ToWireHeader)] }, + Headers = new HttpHeadersWire + { + Entries = [.. response.Headers.Select(ToWireHeader)], + }, Version = ToWireVersion(response.Version), Code = response.StatusCode, }, @@ -473,7 +486,9 @@ List headers { var version = FromWireVersion(responseWire.Version); - var headers = responseWire.Headers.Entries.Select(h => new HttpHeader(h.Name, h.Value, false)).ToList(); + var headers = responseWire + .Headers.Entries.Select(h => new HttpHeader(h.Name, h.Value, false)) + .ToList(); return (responseWire.Code, version, headers); } diff --git a/crates/bindings-csharp/Runtime/Internal/Module.cs b/crates/bindings-csharp/Runtime/Internal/Module.cs index ad5c8324381..eebd573cfc5 100644 --- a/crates/bindings-csharp/Runtime/Internal/Module.cs +++ b/crates/bindings-csharp/Runtime/Internal/Module.cs @@ -129,9 +129,7 @@ internal RawModuleDefV10 BuildModuleDefinition() Sequences: table.Sequences, TableType: table.TableType, TableAccess: table.TableAccess, - DefaultValues: defaults is null - ? [] - : [.. defaults], + DefaultValues: defaults is null ? [] : [.. defaults], IsEvent: table.IsEvent ) ); @@ -211,9 +209,7 @@ internal RawModuleDefV10 BuildModuleDefinition() if (explicitNames.Count > 0) { sections.Add( - new RawModuleDefV10Section.ExplicitNames( - new ExplicitNames([.. explicitNames]) - ) + new RawModuleDefV10Section.ExplicitNames(new ExplicitNames([.. explicitNames])) ); } if (rowLevelSecurityDefs.Count > 0) From 5999c6333018a6ae7d52afb5107916e43b6472f7 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Fri, 10 Jul 2026 11:42:37 -0500 Subject: [PATCH 6/6] Add support to v3 websocket in C# SDK --- sdks/csharp/src/CompressionHelpers.cs | 33 +++- sdks/csharp/src/Plugins/WebSocket.jslib | 23 ++- sdks/csharp/src/SpacetimeDBClient.cs | 45 +++-- sdks/csharp/src/WebSocket.cs | 187 ++++++++++++++++++--- sdks/csharp/src/WebSocketProtocols.cs | 26 +++ sdks/csharp/src/WebSocketProtocols.cs.meta | 11 ++ sdks/csharp/src/WebSocketV3Payload.cs | 93 ++++++++++ sdks/csharp/src/WebSocketV3Payload.cs.meta | 11 ++ sdks/csharp/tests~/SnapshotTests.cs | 65 +++++++ sdks/csharp/tests~/Tests.cs | 148 +++++++++++++++- 10 files changed, 592 insertions(+), 50 deletions(-) create mode 100644 sdks/csharp/src/WebSocketProtocols.cs create mode 100644 sdks/csharp/src/WebSocketProtocols.cs.meta create mode 100644 sdks/csharp/src/WebSocketV3Payload.cs create mode 100644 sdks/csharp/src/WebSocketV3Payload.cs.meta diff --git a/sdks/csharp/src/CompressionHelpers.cs b/sdks/csharp/src/CompressionHelpers.cs index 832208938ed..6a00b74b39b 100644 --- a/sdks/csharp/src/CompressionHelpers.cs +++ b/sdks/csharp/src/CompressionHelpers.cs @@ -41,21 +41,20 @@ internal static GZipStream GzipReader(Stream stream) } /// - /// Decompresses and decodes a serialized from a byte array, + /// Decompresses a serialized from a byte array, /// automatically handling the specified compression algorithm (None, Brotli, or Gzip). /// Ensures efficient decompression by reading the entire stream at once to avoid /// performance issues with certain stream implementations. /// Throws if an unknown compression type is encountered. /// /// The compressed and encoded server message as a byte array. - /// The deserialized object. - internal static ServerMessage DecompressDecodeMessage(byte[] bytes) + /// The decompressed encoded object. + internal static byte[] DecompressMessagePayload(byte[] bytes) { using var stream = new MemoryStream(bytes); // The stream will never be empty. It will at least contain the compression algo. var compression = (CompressionAlgos)stream.ReadByte(); - // Conditionally decompress and decode. Stream decompressedStream = compression switch { CompressionAlgos.None => stream, @@ -67,11 +66,31 @@ internal static ServerMessage DecompressDecodeMessage(byte[] bytes) // TODO: consider pooling these. // DO NOT TRY TO TAKE THIS OUT. The BrotliStream ReadByte() implementation allocates an array // PER BYTE READ. You have to do it all at once to avoid that problem. - MemoryStream memoryStream = new MemoryStream(); + using var memoryStream = new MemoryStream(); decompressedStream.CopyTo(memoryStream); - memoryStream.Seek(0, SeekOrigin.Begin); - return new ServerMessage.BSATN().Read(new BinaryReader(memoryStream)); + return memoryStream.ToArray(); } + /// + /// Decodes a serialized from a byte array. + /// + /// The encoded server message as a byte array. + /// The deserialized object. + internal static ServerMessage DecodeServerMessage(byte[] bytes) + { + using var stream = new MemoryStream(bytes); + using var reader = new BinaryReader(stream); + return new ServerMessage.BSATN().Read(reader); + } + /// + /// Decompresses and decodes a serialized from a byte array, + /// automatically handling the specified compression algorithm (None, Brotli, or Gzip). + /// Ensures efficient decompression by reading the entire stream at once to avoid + /// performance issues with certain stream implementations. + /// Throws if an unknown compression type is encountered. + /// + /// The compressed and encoded server message as a byte array. + /// The deserialized object. + internal static ServerMessage DecompressDecodeMessage(byte[] bytes) => DecodeServerMessage(DecompressMessagePayload(bytes)); /// /// Prepare to read a BsatnRowList. diff --git a/sdks/csharp/src/Plugins/WebSocket.jslib b/sdks/csharp/src/Plugins/WebSocket.jslib index 820d4a17125..69c2fad5062 100644 --- a/sdks/csharp/src/Plugins/WebSocket.jslib +++ b/sdks/csharp/src/Plugins/WebSocket.jslib @@ -33,6 +33,9 @@ mergeInto(LibraryManager.library, { var host = UTF8ToString(baseUriPtr); var uri = UTF8ToString(uriPtr); var protocol = UTF8ToString(protocolPtr); + // The C# WebGL bridge can only pass one string argument here, so + // multiple offered subprotocols are marshalled as a comma-separated string. + var offeredProtocols = protocol.indexOf(',') === -1 ? protocol : protocol.split(','); var authToken = UTF8ToString(authTokenPtr); if (authToken) { @@ -55,7 +58,7 @@ mergeInto(LibraryManager.library, { } } - var socket = new window.WebSocket(uri, protocol); + var socket = new window.WebSocket(uri, offeredProtocols); socket.binaryType = "arraybuffer"; var socketId = manager.nextId++; @@ -63,7 +66,23 @@ mergeInto(LibraryManager.library, { socket.onopen = function() { if (manager.callbacks.open) { - WebSocketDynCall('vi', manager.callbacks.open, [socketId]); + var protocolStr = socket.protocol || ""; + // Marshal the negotiated subprotocol to C# just for the duration of + // this callback. We use stack allocation because the pointer only + // needs to remain valid while dynCall is executing synchronously. + var protocolLength = lengthBytesUTF8(protocolStr) + 1; + var stack = stackSave(); + try { + var protocolPtr = stackAlloc(protocolLength); + // Write a temporary null-terminated UTF-8 string into the + // Emscripten stack frame so the C# callback can copy it. + stringToUTF8(protocolStr, protocolPtr, protocolLength); + WebSocketDynCall('vii', manager.callbacks.open, [socketId, protocolPtr]); + } finally { + // Release the temporary stack allocation immediately after + // the callback returns; C# must not retain the pointer. + stackRestore(stack); + } } }; diff --git a/sdks/csharp/src/SpacetimeDBClient.cs b/sdks/csharp/src/SpacetimeDBClient.cs index 6df6c6155e8..7c6fe5009e3 100644 --- a/sdks/csharp/src/SpacetimeDBClient.cs +++ b/sdks/csharp/src/SpacetimeDBClient.cs @@ -169,6 +169,8 @@ public abstract class DbConnectionBase : IDbConne protected abstract IErrorContext ToErrorContext(Exception errorContext); protected abstract IProcedureEventContext ToProcedureEventContext(ProcedureEvent procedureEvent); + private Func decodeTransportMessages = DecodeV2TransportMessages; + private readonly ConcurrentDictionary> waitingOneOffQueries = new(); private readonly ConcurrentDictionary pendingReducerCalls = new(); @@ -220,10 +222,16 @@ protected DbConnectionBase() { var options = new WebSocket.ConnectOptions { - Protocol = "v2.bsatn.spacetimedb" + Protocols = WebSocketProtocols.Preferred }; webSocket = new WebSocket(options); webSocket.OnMessage += OnMessageReceived; + webSocket.OnProtocolNegotiated += protocolVersion => + { + decodeTransportMessages = protocolVersion == WebSocketProtocolVersion.V3 + ? WebSocketV3Payload.DecodeServerMessages + : DecodeV2TransportMessages; + }; webSocket.OnSendError += a => onSendError?.Invoke(a); #if UNITY_5_3_OR_NEWER webSocket.OnClose += (e) => @@ -289,6 +297,8 @@ internal struct ParsedMessage private static readonly Status Committed = new Status.Committed(default); + private static byte[][] DecodeV2TransportMessages(byte[] payload) => new[] { payload }; + /// /// Get a description of a message suitable for storing in the tracker metadata. /// @@ -427,9 +437,18 @@ void ParseOneOffQuery(OneOffQueryResult resp) #endif try { - var message = _parseQueue.Take(_parseCancellationToken); - var parsedMessage = ParseMessage(message); - _applyQueue.Add(parsedMessage, _parseCancellationToken); + var unparsed = _parseQueue.Take(_parseCancellationToken); + var payload = CompressionHelpers.DecompressMessagePayload(unparsed.bytes); + var decodedMessages = decodeTransportMessages(payload); + stats.ParseMessageQueueTracker.FinishTrackingRequest( + unparsed.parseQueueTrackerId, + $"type=ws_payload,count={decodedMessages.Length}" + ); + foreach (var messageBytes in decodedMessages) + { + var parsedMessage = ParseMessage(messageBytes, unparsed.timestamp); + _applyQueue.Add(parsedMessage, _parseCancellationToken); + } } catch (OperationCanceledException) { @@ -452,13 +471,12 @@ void ParseOneOffQuery(OneOffQueryResult resp) } } - ParsedMessage ParseMessage(UnparsedMessage unparsed) + ParsedMessage ParseMessage(byte[] messageBytes, DateTime timestamp) { var dbOps = ParsedDatabaseUpdate.New(); - var message = CompressionHelpers.DecompressDecodeMessage(unparsed.bytes); + var message = CompressionHelpers.DecodeServerMessage(messageBytes); var trackerMetadata = TrackerMetadataForMessage(message); - stats.ParseMessageQueueTracker.FinishTrackingRequest(unparsed.parseQueueTrackerId, trackerMetadata); var parseStart = DateTime.UtcNow; ReducerEvent? reducerEvent = default; @@ -469,11 +487,11 @@ ParsedMessage ParseMessage(UnparsedMessage unparsed) case ServerMessage.InitialConnection: break; case ServerMessage.SubscribeApplied(var subscribeApplied): - stats.SubscriptionRequestTracker.FinishTrackingRequest(subscribeApplied.RequestId, unparsed.timestamp); + stats.SubscriptionRequestTracker.FinishTrackingRequest(subscribeApplied.RequestId, timestamp); dbOps = ParseSubscribeRows(subscribeApplied.Rows); break; case ServerMessage.UnsubscribeApplied(var unsubscribeApplied): - stats.SubscriptionRequestTracker.FinishTrackingRequest(unsubscribeApplied.RequestId, unparsed.timestamp); + stats.SubscriptionRequestTracker.FinishTrackingRequest(unsubscribeApplied.RequestId, timestamp); if (unsubscribeApplied.Rows != null) { dbOps = ParseUnsubscribeRows(unsubscribeApplied.Rows); @@ -482,7 +500,7 @@ ParsedMessage ParseMessage(UnparsedMessage unparsed) case ServerMessage.SubscriptionError(var subscriptionError): if (subscriptionError.RequestId.HasValue) { - stats.SubscriptionRequestTracker.FinishTrackingRequest(subscriptionError.RequestId.Value, unparsed.timestamp); + stats.SubscriptionRequestTracker.FinishTrackingRequest(subscriptionError.RequestId.Value, timestamp); } break; case ServerMessage.TransactionUpdate(var transactionUpdate): @@ -492,7 +510,7 @@ ParsedMessage ParseMessage(UnparsedMessage unparsed) ParseOneOffQuery(resp); break; case ServerMessage.ReducerResult(var reducerResult): - if (!stats.ReducerRequestTracker.FinishTrackingRequest(reducerResult.RequestId, unparsed.timestamp)) + if (!stats.ReducerRequestTracker.FinishTrackingRequest(reducerResult.RequestId, timestamp)) { Log.Warn($"Failed to finish tracking reducer request: {reducerResult.RequestId}"); } @@ -545,7 +563,7 @@ ParsedMessage ParseMessage(UnparsedMessage unparsed) procedureResult.RequestId ); - if (!stats.ProcedureRequestTracker.FinishTrackingRequest(procedureResult.RequestId, unparsed.timestamp)) + if (!stats.ProcedureRequestTracker.FinishTrackingRequest(procedureResult.RequestId, timestamp)) { Log.Warn($"Failed to finish tracking procedure request: {procedureResult.RequestId}"); } @@ -558,7 +576,7 @@ ParsedMessage ParseMessage(UnparsedMessage unparsed) stats.ParseMessageTracker.InsertRequest(parseStart, trackerMetadata); var applyTracker = stats.ApplyMessageQueueTracker.StartTrackingRequest(trackerMetadata); - return new ParsedMessage { message = message, dbOps = dbOps, receiveTimestamp = unparsed.timestamp, applyQueueTrackerId = applyTracker, reducerEvent = reducerEvent, procedureEvent = procedureEvent }; + return new ParsedMessage { message = message, dbOps = dbOps, receiveTimestamp = timestamp, applyQueueTrackerId = applyTracker, reducerEvent = reducerEvent, procedureEvent = procedureEvent }; } } @@ -609,6 +627,7 @@ void IDbConnection.Connect(string? token, string uri, string addressOrName, Comp { isClosing = false; connectionClosed = false; + decodeTransportMessages = DecodeV2TransportMessages; Identity = null; initialConnectionId = null; onConnectInvoked = false; diff --git a/sdks/csharp/src/WebSocket.cs b/sdks/csharp/src/WebSocket.cs index 26ce87127ba..1461c92bb8e 100644 --- a/sdks/csharp/src/WebSocket.cs +++ b/sdks/csharp/src/WebSocket.cs @@ -3,7 +3,7 @@ using System; using System.Collections.Concurrent; -using System.Linq; +using System.Collections.Generic; using System.Net.Sockets; using System.Net.WebSockets; using System.Runtime.InteropServices; @@ -15,6 +15,8 @@ namespace SpacetimeDB { internal class WebSocket { + private delegate (byte[] EncodedMessage, bool ShouldYield) DequeueSendWork(); + public delegate void OpenEventHandler(); public delegate void MessageEventHandler(byte[] message, DateTime timestamp); @@ -26,7 +28,7 @@ internal class WebSocket public struct ConnectOptions { - public string Protocol; + public string[] Protocols; } // WebSocket buffer for incoming messages @@ -36,13 +38,16 @@ public struct ConnectOptions private readonly ConnectOptions _options; private readonly byte[] _receiveBuffer = new byte[MAXMessageSize]; private readonly ConcurrentQueue dispatchQueue = new(); + private static readonly ClientMessage.BSATN clientMessageBsatn = new(); protected ClientWebSocket Ws = new(); private CancellationTokenSource? _connectCts; + private DequeueSendWork dequeueSendWork; public WebSocket(ConnectOptions options) { _options = options; + dequeueSendWork = DequeueV2SendWork; #if UNITY_WEBGL && !UNITY_EDITOR InitializeWebGL(); #endif @@ -57,6 +62,14 @@ public WebSocket(ConnectOptions options) /// public event MessageEventHandler? OnMessage; public event CloseEventHandler? OnClose; + public event Action? OnProtocolNegotiated; + + private WebSocketProtocolVersion protocolVersion = WebSocketProtocolVersion.V2; + public WebSocketProtocolVersion ProtocolVersion + { + get => protocolVersion; + internal set => SetProtocolVersion(value); + } #if UNITY_WEBGL && !UNITY_EDITOR private bool _isConnected = false; @@ -88,10 +101,13 @@ IntPtr errorCallback [DllImport("__Internal")] private static extern void WebSocket_Close(int socketId, int code, string reason); - [AOT.MonoPInvokeCallback(typeof(Action))] - private static void WebGLOnOpen(int socketId) + [AOT.MonoPInvokeCallback(typeof(Action))] + private static void WebGLOnOpen(int socketId, IntPtr protocolPtr) { - Instance?.HandleWebGLOpen(socketId); + // The JS bridge passes a temporary UTF-8 pointer that is only valid for + // this callback, so copy it into a managed string immediately. + var protocol = Marshal.PtrToStringUTF8(protocolPtr) ?? string.Empty; + Instance?.HandleWebGLOpen(socketId, protocol); } [AOT.MonoPInvokeCallback(typeof(Action))] @@ -137,7 +153,7 @@ private void InitializeWebGL() { Instance = this; // Convert callbacks to function pointers - var openPtr = Marshal.GetFunctionPointerForDelegate((Action)WebGLOnOpen); + var openPtr = Marshal.GetFunctionPointerForDelegate((Action)WebGLOnOpen); var messagePtr = Marshal.GetFunctionPointerForDelegate((Action)WebGLOnMessage); var closePtr = Marshal.GetFunctionPointerForDelegate((Action)WebGLOnClose); var errorPtr = Marshal.GetFunctionPointerForDelegate((Action)WebGLOnError); @@ -148,6 +164,7 @@ private void InitializeWebGL() public async Task Connect(string? auth, string host, string nameOrAddress, ConnectionId connectionId, Compression compression, bool light, bool? confirmedReads) { + ResetProtocolVersion(); #if UNITY_WEBGL && !UNITY_EDITOR if (_isConnecting || _isConnected) return; @@ -166,7 +183,7 @@ public async Task Connect(string? auth, string host, string nameOrAddress, Conne _socketId = new TaskCompletionSource(); var callbackPtr = Marshal.GetFunctionPointerForDelegate((Action)OnSocketIdReceived); - WebSocket_Connect(host, uri, _options.Protocol, auth, callbackPtr); + WebSocket_Connect(host, uri, WebSocketProtocols.SerializeOfferedProtocols(_options.Protocols), auth, callbackPtr); _webglSocketId = await _socketId.Task; if (_webglSocketId == -1) { @@ -189,6 +206,7 @@ public async Task Connect(string? auth, string host, string nameOrAddress, Conne } // Events will be handled via UnitySendMessage callbacks #else + Ws = new ClientWebSocket(); var uri = $"{host}/v1/database/{nameOrAddress}/subscribe?connection_id={connectionId}&compression={compression}"; if (light) { @@ -201,7 +219,10 @@ public async Task Connect(string? auth, string host, string nameOrAddress, Conne uri += $"&confirmed={enabled}"; } var url = new Uri(uri); - Ws.Options.AddSubProtocol(_options.Protocol); + foreach (var protocol in _options.Protocols) + { + Ws.Options.AddSubProtocol(protocol); + } _connectCts = new CancellationTokenSource(10000); if (!string.IsNullOrEmpty(auth)) @@ -218,6 +239,7 @@ public async Task Connect(string? auth, string host, string nameOrAddress, Conne await Ws.ConnectAsync(url, _connectCts.Token); if (Ws.State == WebSocketState.Open) { + SetProtocolVersion(WebSocketProtocols.Normalize(Ws.SubProtocol)); if (OnConnect != null) { dispatchQueue.Enqueue(() => OnConnect()); @@ -373,7 +395,8 @@ await Ws.CloseAsync(WebSocketCloseStatus.MessageTooBig, closeMessage, if (OnMessage != null) { - var message = _receiveBuffer.Take(count).ToArray(); + var message = new byte[count]; + Buffer.BlockCopy(_receiveBuffer, 0, message, 0, count); // directly invoke message handling OnMessage(message, startReceive); } @@ -454,8 +477,8 @@ public void Abort() #endif } - private Task? senderTask; - private readonly ConcurrentQueue messageSendQueue = new(); + private bool senderActive; + private readonly Queue messageSendQueue = new(); /// /// This sender guarantees that that messages are sent out in the order they are received. Our websocket @@ -465,25 +488,62 @@ public void Abort() /// The message to send public void Send(ClientMessage message) { -#if UNITY_WEBGL && !UNITY_EDITOR try { - var messageBSATN = new ClientMessage.BSATN(); - var encodedMessage = IStructuralReadWrite.ToBytes(messageBSATN, message); - WebSocket_Send(_webglSocketId, encodedMessage, encodedMessage.Length); + var encodedMessage = IStructuralReadWrite.ToBytes(clientMessageBsatn, message); + var startProcessor = false; + lock (messageSendQueue) + { + messageSendQueue.Enqueue(encodedMessage); + if (!senderActive) + { + senderActive = true; + startProcessor = true; + } + } + + if (startProcessor) + { + _ = StartProcessSendQueue(); + } } catch (Exception e) { - UnityEngine.Debug.LogError($"WebSocket send error: {e}"); dispatchQueue.Enqueue(() => OnSendError?.Invoke(e)); } + } + + private Task StartProcessSendQueue() + { +#if UNITY_WEBGL && !UNITY_EDITOR + return ProcessSendQueue(); #else + return Task.Run(ProcessSendQueue); +#endif + } + + private void ScheduleSendQueueContinuation() + { +#if UNITY_WEBGL && !UNITY_EDITOR + dispatchQueue.Enqueue(TryStartSendQueueProcessor); +#else + _ = Task.Run(TryStartSendQueueProcessor); +#endif + } + + private void TryStartSendQueueProcessor() + { lock (messageSendQueue) { - messageSendQueue.Enqueue(message); - senderTask ??= Task.Run(ProcessSendQueue); + if (senderActive || messageSendQueue.Count == 0) + { + return; + } + + senderActive = true; } -#endif + + _ = StartProcessSendQueue(); } private async Task ProcessSendQueue() @@ -492,37 +552,109 @@ private async Task ProcessSendQueue() { while (true) { - ClientMessage message; + byte[] encodedMessage; + bool shouldYield; lock (messageSendQueue) { - if (!messageSendQueue.TryDequeue(out message)) + if (messageSendQueue.Count == 0) { // We are out of messages to send - senderTask = null; + senderActive = false; return; } + + (encodedMessage, shouldYield) = dequeueSendWork(); } - var messageBSATN = new ClientMessage.BSATN(); - var encodedMessage = IStructuralReadWrite.ToBytes(messageBSATN, message); - await Ws!.SendAsync(encodedMessage, WebSocketMessageType.Binary, true, CancellationToken.None); + await SendEncodedMessage(encodedMessage); + + if (shouldYield) + { + // After sending one capped v3 payload, stop this queue pump and + // schedule a follow-up pass using the platform's existing dispatch path. + lock (messageSendQueue) + { + senderActive = false; + } + ScheduleSendQueueContinuation(); + return; + } } } catch (Exception e) { - senderTask = null; + lock (messageSendQueue) + { + senderActive = false; + } if (OnSendError != null) dispatchQueue.Enqueue(() => OnSendError(e)); } } + private byte[][] DequeueMessagesForV3Payload() + { + var messageCount = WebSocketV3Payload.CountClientMessagesThatFitInPayload(messageSendQueue); + if (messageCount <= 0) + { + throw new InvalidOperationException("Expected at least one queued v2 message when building a v3 payload."); + } + + var messages = new byte[messageCount][]; + for (var i = 0; i < messageCount; i++) + { + messages[i] = messageSendQueue.Dequeue(); + } + return messages; + } + + private (byte[] EncodedMessage, bool ShouldYield) DequeueV2SendWork() => + (messageSendQueue.Dequeue(), false); + + private (byte[] EncodedMessage, bool ShouldYield) DequeueV3SendWork() + { + var queuedMessages = DequeueMessagesForV3Payload(); + return (WebSocketV3Payload.EncodeClientMessages(queuedMessages), messageSendQueue.Count > 0); + } + + private void ResetProtocolVersion() + { + protocolVersion = WebSocketProtocolVersion.V2; + dequeueSendWork = DequeueV2SendWork; + } + + private void SetProtocolVersion(WebSocketProtocolVersion protocolVersion) + { + // Protocol selection is a transport concern: changing it swaps the + // active send strategy and notifies higher layers to swap their receive decoder. + this.protocolVersion = protocolVersion; + dequeueSendWork = protocolVersion == WebSocketProtocolVersion.V3 + ? DequeueV3SendWork + : DequeueV2SendWork; + OnProtocolNegotiated?.Invoke(protocolVersion); + } + + private Task SendEncodedMessage(byte[] encodedMessage) + { +#if UNITY_WEBGL && !UNITY_EDITOR + var result = WebSocket_Send(_webglSocketId, encodedMessage, encodedMessage.Length); + if (result != 0) + { + throw new InvalidOperationException("WebSocket send failed."); + } + return Task.CompletedTask; +#else + return Ws!.SendAsync(new ArraySegment(encodedMessage), WebSocketMessageType.Binary, true, CancellationToken.None); +#endif + } + public WebSocketState GetState() { return Ws!.State; } #if UNITY_WEBGL && !UNITY_EDITOR - public void HandleWebGLOpen(int socketId) + public void HandleWebGLOpen(int socketId, string protocol) { if (socketId == _webglSocketId) { @@ -535,6 +667,7 @@ public void HandleWebGLOpen(int socketId) _cancelConnectRequested = false; return; } + SetProtocolVersion(WebSocketProtocols.Normalize(protocol)); _isConnected = true; if (OnConnect != null) dispatchQueue.Enqueue(() => OnConnect()); diff --git a/sdks/csharp/src/WebSocketProtocols.cs b/sdks/csharp/src/WebSocketProtocols.cs new file mode 100644 index 00000000000..0e98ec0c48c --- /dev/null +++ b/sdks/csharp/src/WebSocketProtocols.cs @@ -0,0 +1,26 @@ +namespace SpacetimeDB +{ + internal enum WebSocketProtocolVersion + { + V2, + V3, + } + + internal static class WebSocketProtocols + { + internal const string V2 = "v2.bsatn.spacetimedb"; + internal const string V3 = "v3.bsatn.spacetimedb"; + + internal static readonly string[] Preferred = new[] { V3, V2 }; + + internal static WebSocketProtocolVersion Normalize(string? protocol) + { + // Treat an empty negotiated subprotocol as legacy v2 defensively. + return protocol == V3 ? WebSocketProtocolVersion.V3 : WebSocketProtocolVersion.V2; + } + +#if UNITY_WEBGL && !UNITY_EDITOR + internal static string SerializeOfferedProtocols(string[] protocols) => string.Join(",", protocols); +#endif + } +} diff --git a/sdks/csharp/src/WebSocketProtocols.cs.meta b/sdks/csharp/src/WebSocketProtocols.cs.meta new file mode 100644 index 00000000000..8188de05b37 --- /dev/null +++ b/sdks/csharp/src/WebSocketProtocols.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7d90c829f2c54180a62cf2a4f75c7755 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/sdks/csharp/src/WebSocketV3Payload.cs b/sdks/csharp/src/WebSocketV3Payload.cs new file mode 100644 index 00000000000..2166910b21f --- /dev/null +++ b/sdks/csharp/src/WebSocketV3Payload.cs @@ -0,0 +1,93 @@ +using SpacetimeDB.BSATN; +using SpacetimeDB.ClientApi; + +using System; +using System.Collections.Generic; +using System.IO; + +namespace SpacetimeDB +{ + internal static class WebSocketV3Payload + { + internal const int MaxPayloadBytes = 256 * 1024; + + private static readonly ServerMessage.BSATN serverMessageBsatn = new(); + + internal static byte[] EncodeClientMessages(IReadOnlyList messages) + { + if (messages.Count == 0) + { + throw new InvalidOperationException("Cannot encode an empty v3 client payload."); + } + + var payloadBytes = 0; + for (var i = 0; i < messages.Count; i++) + { + payloadBytes += messages[i].Length; + } + + var payload = new byte[payloadBytes]; + var offset = 0; + for (var i = 0; i < messages.Count; i++) + { + Buffer.BlockCopy(messages[i], 0, payload, offset, messages[i].Length); + offset += messages[i].Length; + } + return payload; + } + + internal static byte[][] DecodeServerMessages(byte[] payload) + { + if (payload.Length == 0) + { + throw new InvalidOperationException("Cannot decode an empty v3 server payload."); + } + + using var stream = new MemoryStream(payload); + using var reader = new BinaryReader(stream); + var messages = new List(); + + while (stream.Position < stream.Length) + { + var start = stream.Position; + serverMessageBsatn.Read(reader); + var end = stream.Position; + + var message = new byte[end - start]; + Buffer.BlockCopy(payload, (int)start, message, 0, message.Length); + messages.Add(message); + } + + return messages.ToArray(); + } + + internal static int CountClientMessagesThatFitInPayload( + IEnumerable messages, + int maxPayloadBytes = MaxPayloadBytes + ) + { + var messageCount = 0; + var payloadBytes = 0; + + foreach (var message in messages) + { + if (messageCount == 0) + { + if (message.Length > maxPayloadBytes) + { + return 1; + } + } + else if (payloadBytes + message.Length > maxPayloadBytes) + { + break; + } + + messageCount++; + payloadBytes += message.Length; + } + + return messageCount; + } + } +} diff --git a/sdks/csharp/src/WebSocketV3Payload.cs.meta b/sdks/csharp/src/WebSocketV3Payload.cs.meta new file mode 100644 index 00000000000..fc66ee83984 --- /dev/null +++ b/sdks/csharp/src/WebSocketV3Payload.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d3fd3ef1731a4e729e031669920af3ae +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/sdks/csharp/tests~/SnapshotTests.cs b/sdks/csharp/tests~/SnapshotTests.cs index 95a188c8553..9d2f179a425 100644 --- a/sdks/csharp/tests~/SnapshotTests.cs +++ b/sdks/csharp/tests~/SnapshotTests.cs @@ -381,6 +381,71 @@ public static IEnumerable SampleDump() } + [Fact] + public void V3CoalescedServerPayloadIsProcessedInOrder() + { + DbConnection.IsTesting = true; + + var client = + DbConnection.Builder() + .WithUri("wss://spacetimedb.com") + .WithDatabaseName("example") + .Build(); + + client.webSocket.ProtocolVersion = WebSocketProtocolVersion.V3; + + ServerMessage initialConnection = SampleId( + "j5DMlKmWjfbSl7qmZQOok7HDSwsAJopRSJjdlUsNogs=", + "token", + "Vd4dFzcEzhLHJ6uNL8VXFg==" + ); + ServerMessage transactionUpdate = SampleTransactionUpdate( + 1, + [SampleUserInsert("l0qzG1GPRtC1mwr+54q98tv0325gozLc6cNzq4vrzqY=", "A", true)] + ); + + var initialPayload = IStructuralReadWrite.ToBytes(new ServerMessage.BSATN(), initialConnection); + var updatePayload = IStructuralReadWrite.ToBytes(new ServerMessage.BSATN(), transactionUpdate); + var payload = new byte[initialPayload.Length + updatePayload.Length]; + Buffer.BlockCopy(initialPayload, 0, payload, 0, initialPayload.Length); + Buffer.BlockCopy(updatePayload, 0, payload, initialPayload.Length, updatePayload.Length); + + var transportMessage = new byte[payload.Length + 1]; + transportMessage[0] = 0; + Buffer.BlockCopy(payload, 0, transportMessage, 1, payload.Length); + + client.OnMessageReceived(transportMessage, DateTime.UtcNow); + + var deadline = DateTime.UtcNow.AddSeconds(2); + List? users = null; + while (true) + { + client.FrameTick(); + users = client.Db.User.Iter().ToList(); + if (users.Count == 1) + { + break; + } + + if (DateTime.UtcNow >= deadline) + { + throw new TimeoutException("Timed out waiting for a v3 coalesced payload to be applied."); + } + Thread.Sleep(1); + } + + Assert.Equal( + Identity.From(Convert.FromBase64String("j5DMlKmWjfbSl7qmZQOok7HDSwsAJopRSJjdlUsNogs=")), + client.Identity + ); + + Assert.Single(users); + Assert.Equal("A", users[0].Name); + Assert.True(users[0].Online); + + client.Disconnect(); + } + [Theory] [MemberData(nameof(SampleDump))] public async Task VerifySampleDump(string dumpName, ServerMessage[] sampleDumpParsed) diff --git a/sdks/csharp/tests~/Tests.cs b/sdks/csharp/tests~/Tests.cs index 3adb4970cba..69e10fd9909 100644 --- a/sdks/csharp/tests~/Tests.cs +++ b/sdks/csharp/tests~/Tests.cs @@ -1,7 +1,11 @@ using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Net.WebSockets; using CsCheck; using SpacetimeDB; using SpacetimeDB.BSATN; +using SpacetimeDB.ClientApi; using SpacetimeDB.Types; public class Tests @@ -128,4 +132,146 @@ public static void ListstreamWorks() } }); } -} \ No newline at end of file + + [Fact] + public static void V3PayloadSizingCapsAt256KiB() + { + var messages = new[] + { + new byte[100_000], + new byte[100_000], + new byte[100_000], + }; + + Assert.Equal(2, WebSocketV3Payload.CountClientMessagesThatFitInPayload(messages)); + Assert.Equal(1, WebSocketV3Payload.CountClientMessagesThatFitInPayload(new[] { new byte[300_000] })); + Assert.Equal(0, WebSocketV3Payload.CountClientMessagesThatFitInPayload(Array.Empty())); + } + + [Fact] + public static void V3ServerPayloadDecodeHandlesSingleAndCoalescedMessages() + { + static byte[] EncodeMessage(ServerMessage message) => + IStructuralReadWrite.ToBytes(new ServerMessage.BSATN(), message); + + static byte[] Concat(params byte[][] messages) + { + var payload = new byte[messages.Sum(message => message.Length)]; + var offset = 0; + foreach (var message in messages) + { + Buffer.BlockCopy(message, 0, payload, offset, message.Length); + offset += message.Length; + } + return payload; + } + + var singlePayload = EncodeMessage(new ServerMessage.SubscriptionError(new() + { + RequestId = null, + QueryId = 1, + Error = "single", + })); + var single = WebSocketV3Payload.DecodeServerMessages(singlePayload); + Assert.Single(single); + Assert.Equal(singlePayload, single[0]); + + var firstPayload = EncodeMessage(new ServerMessage.SubscriptionError(new() + { + RequestId = null, + QueryId = 2, + Error = "first", + })); + var secondPayload = EncodeMessage(new ServerMessage.SubscriptionError(new() + { + RequestId = null, + QueryId = 3, + Error = "second", + })); + var batch = WebSocketV3Payload.DecodeServerMessages(Concat(firstPayload, secondPayload)); + Assert.Equal(2, batch.Length); + Assert.Equal(firstPayload, batch[0]); + Assert.Equal(secondPayload, batch[1]); + } + + [Fact] + public static async Task WebSocketFallsBackToV2WhenServerOnlyNegotiatesV2() + { + static int GetFreePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + + static async Task WaitForAsync(Task task, SpacetimeDB.WebSocket ws, string error) + { + var deadline = DateTime.UtcNow.AddSeconds(5); + while (!task.IsCompleted) + { + ws.Update(); + if (DateTime.UtcNow >= deadline) + { + throw new TimeoutException(error); + } + await Task.Delay(10); + } + + await task; + } + + var port = GetFreePort(); + using var listener = new HttpListener(); + listener.Prefixes.Add($"http://127.0.0.1:{port}/"); + listener.Start(); + + var serverObservedProtocols = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var serverTask = Task.Run(async () => + { + var context = await listener.GetContextAsync(); + serverObservedProtocols.TrySetResult(context.Request.Headers["Sec-WebSocket-Protocol"] ?? string.Empty); + + var webSocketContext = await context.AcceptWebSocketAsync(WebSocketProtocols.V2); + await Task.Delay(100); + await webSocketContext.WebSocket.CloseAsync( + WebSocketCloseStatus.NormalClosure, + "done", + CancellationToken.None + ); + }); + + var ws = new SpacetimeDB.WebSocket(new SpacetimeDB.WebSocket.ConnectOptions + { + Protocols = WebSocketProtocols.Preferred, + }); + + var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + ws.OnConnect += () => connected.TrySetResult(); + ws.OnClose += _ => closed.TrySetResult(); + + var clientTask = Task.Run(() => ws.Connect( + "test-token", + $"ws://127.0.0.1:{port}", + "example", + ConnectionId.Random(), + Compression.None, + false, + null + )); + + await WaitForAsync(connected.Task, ws, "Timed out waiting for websocket connection."); + + Assert.Equal(WebSocketProtocolVersion.V2, ws.ProtocolVersion); + + var offeredProtocols = await serverObservedProtocols.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Contains(WebSocketProtocols.V3, offeredProtocols); + Assert.Contains(WebSocketProtocols.V2, offeredProtocols); + + await WaitForAsync(closed.Task, ws, "Timed out waiting for websocket close."); + await serverTask.WaitAsync(TimeSpan.FromSeconds(5)); + await clientTask.WaitAsync(TimeSpan.FromSeconds(5)); + } +}