diff --git a/.github/workflows/apicompat.yml b/.github/workflows/apicompat.yml
index 8ead46507..314aa94aa 100644
--- a/.github/workflows/apicompat.yml
+++ b/.github/workflows/apicompat.yml
@@ -24,7 +24,10 @@ jobs:
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
- dotnet-version: 8.x
+ dotnet-version: |
+ 6.x
+ 8.x
+ 10.x
- name: Run API Compatibility Check
run: bash scripts/apicompat.sh --build
diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml
index b5b27b217..c0c97c69d 100644
--- a/.github/workflows/docs-preview.yml
+++ b/.github/workflows/docs-preview.yml
@@ -24,7 +24,7 @@ jobs:
dotnet-version: |
6.x
8.x
- 9.x
+ 10.x
- run: dotnet build
- run: dotnet tool update -g docfx
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index dd43ee9b1..a2cf2a43e 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -28,7 +28,7 @@ jobs:
dotnet-version: |
6.x
8.x
- 9.x
+ 10.x
- run: dotnet build
- run: dotnet tool update -g docfx
diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml
index 47d50b431..a45db11f1 100644
--- a/.github/workflows/format.yml
+++ b/.github/workflows/format.yml
@@ -17,7 +17,7 @@ jobs:
dotnet-version: |
6.x
8.x
- 9.x
+ 10.x
- name: Check formatting
run: |
diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml
index fa0d8fc43..55ee1ac7f 100644
--- a/.github/workflows/perf.yml
+++ b/.github/workflows/perf.yml
@@ -45,7 +45,7 @@ jobs:
dotnet-version: |
6.x
8.x
- 9.x
+ 10.x
- name: Release Build
run: dotnet build -c Release tests/NATS.Client.Perf/NATS.Client.Perf.csproj
diff --git a/.github/workflows/test_linux.yml b/.github/workflows/test_linux.yml
index 0cae81f63..d62759c97 100644
--- a/.github/workflows/test_linux.yml
+++ b/.github/workflows/test_linux.yml
@@ -44,7 +44,7 @@ jobs:
dotnet-version: |
6.x
8.x
- 9.x
+ 10.x
- name: Build
run: |
@@ -71,6 +71,11 @@ jobs:
cd tests/NATS.Client.Abstractions.Tests
dotnet test -c Release --no-build
+ - name: Test Object Store Encoder
+ run: |
+ cd tests/NATS.Client.ObjectStore.Encoder.Tests
+ dotnet test -c Release --no-build
+
- name: Test JetStream
run: |
killall nats-server 2> /dev/null | echo -n
diff --git a/.github/workflows/test_linux_core.yml b/.github/workflows/test_linux_core.yml
index 8d90af12c..adedb84e1 100644
--- a/.github/workflows/test_linux_core.yml
+++ b/.github/workflows/test_linux_core.yml
@@ -44,7 +44,7 @@ jobs:
dotnet-version: |
6.x
8.x
- 9.x
+ 10.x
- name: Build
run: |
diff --git a/.github/workflows/test_windows.yml b/.github/workflows/test_windows.yml
index 44325dbc1..6c14c3107 100644
--- a/.github/workflows/test_windows.yml
+++ b/.github/workflows/test_windows.yml
@@ -36,7 +36,7 @@ jobs:
dotnet-version: |
6.x
8.x
- 9.x
+ 10.x
- name: Set up Go
uses: actions/setup-go@v5
@@ -83,6 +83,12 @@ jobs:
cd tests/NATS.Client.Abstractions.Tests
dotnet test -c Release --no-build
+ # net481 covers the netstandard2.0 branch; run TFMs serially as the platform tests do
+ - name: Test Object Store Encoder
+ run: |
+ cd tests/NATS.Client.ObjectStore.Encoder.Tests
+ dotnet test -c Release --no-build -p:TestTfmsInParallel=false
+
- name: Test JetStream
run: |
tasklist | grep -i nats-server && taskkill -F -IM nats-server.exe
diff --git a/NATS.Net.slnx b/NATS.Net.slnx
index 187f663d9..b6c83097e 100644
--- a/NATS.Net.slnx
+++ b/NATS.Net.slnx
@@ -27,6 +27,7 @@
+
@@ -82,6 +83,7 @@
+
diff --git a/sandbox/MicroBench2/.gitignore b/sandbox/MicroBench2/.gitignore
new file mode 100644
index 000000000..b0f1a2c50
--- /dev/null
+++ b/sandbox/MicroBench2/.gitignore
@@ -0,0 +1 @@
+/BenchmarkDotNet.Artifacts
diff --git a/sandbox/MicroBench2/Base64UrlEncoderBenchmarks.cs b/sandbox/MicroBench2/Base64UrlEncoderBenchmarks.cs
new file mode 100644
index 000000000..244e103ec
--- /dev/null
+++ b/sandbox/MicroBench2/Base64UrlEncoderBenchmarks.cs
@@ -0,0 +1,98 @@
+using BenchmarkDotNet.Attributes;
+using BenchmarkDotNet.Jobs;
+using NATS.Client.ObjectStore.Internal;
+
+namespace MicroBench2;
+
+[MemoryDiagnoser]
+[ShortRunJob(RuntimeMoniker.Net481)]
+[ShortRunJob(RuntimeMoniker.Net80)]
+[ShortRunJob(RuntimeMoniker.Net10_0)]
+public class Base64UrlEncoderBenchmarks
+{
+ private const char PadChar = '=';
+
+ private static readonly char[] SBase64Table =
+ {
+ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
+ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
+ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '_',
+ };
+
+ private byte[] _data = null!;
+
+ // SHA-256 digest (32) is the common case; the others bracket small and larger inputs.
+ [Params(16, 32, 256, 4096)]
+ public int Size { get; set; }
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ _data = new byte[Size];
+ for (var i = 0; i < Size; i++)
+ _data[i] = (byte)((i * 37) + 11);
+ }
+
+ // Baseline: the original lookup-table encoder that allocated a fresh char[] per call.
+ [Benchmark(Baseline = true, Description = "table (new char[])")]
+ public string EncodeTableNewArray() => EncodeTableNewArrayImpl(_data);
+
+ [Benchmark(Description = "current")]
+ public string Encode() => Base64UrlEncoder.Encode(_data);
+
+ private static string EncodeTableNewArrayImpl(Span inArray)
+ {
+ var length = inArray.Length;
+ if (length == 0)
+ return string.Empty;
+
+ var lengthMod3 = length % 3;
+ var limit = length - lengthMod3;
+ var output = new char[(length + 2) / 3 * 4];
+ var table = SBase64Table;
+ var j = 0;
+
+ for (var i = 0; i < limit; i += 3)
+ {
+ var d0 = inArray[i];
+ var d1 = inArray[i + 1];
+ var d2 = inArray[i + 2];
+
+ output[j + 0] = table[d0 >> 2];
+ output[j + 1] = table[((d0 & 0x03) << 4) | (d1 >> 4)];
+ output[j + 2] = table[((d1 & 0x0f) << 2) | (d2 >> 6)];
+ output[j + 3] = table[d2 & 0x3f];
+ j += 4;
+ }
+
+ switch (lengthMod3)
+ {
+ case 2:
+ {
+ var d0 = inArray[limit];
+ var d1 = inArray[limit + 1];
+ output[j + 0] = table[d0 >> 2];
+ output[j + 1] = table[((d0 & 0x03) << 4) | (d1 >> 4)];
+ output[j + 2] = table[(d1 & 0x0f) << 2];
+ j += 3;
+ }
+
+ break;
+
+ case 1:
+ {
+ var d0 = inArray[limit];
+ output[j + 0] = table[d0 >> 2];
+ output[j + 1] = table[(d0 & 0x03) << 4];
+ j += 2;
+ }
+
+ break;
+ }
+
+ for (var k = j; k < output.Length; k++)
+ output[k] = PadChar;
+
+ return new string(output);
+ }
+}
diff --git a/sandbox/MicroBench2/MicroBench2.csproj b/sandbox/MicroBench2/MicroBench2.csproj
new file mode 100644
index 000000000..6cdccf6bb
--- /dev/null
+++ b/sandbox/MicroBench2/MicroBench2.csproj
@@ -0,0 +1,28 @@
+
+
+
+
+ Exe
+ net8.0;net10.0
+
+ $(TargetFrameworks);net481
+ enable
+ enable
+ latest
+ false
+ $(NoWarn);CS8002
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sandbox/MicroBench2/Program.cs b/sandbox/MicroBench2/Program.cs
new file mode 100644
index 000000000..813b138e5
--- /dev/null
+++ b/sandbox/MicroBench2/Program.cs
@@ -0,0 +1,4 @@
+using System.Reflection;
+using BenchmarkDotNet.Running;
+
+BenchmarkSwitcher.FromAssembly(Assembly.GetExecutingAssembly()).Run(args);
diff --git a/src/NATS.Client.ObjectStore/Internal/Encoder.cs b/src/NATS.Client.ObjectStore/Internal/Encoder.cs
index 07aaadc1f..86921a128 100644
--- a/src/NATS.Client.ObjectStore/Internal/Encoder.cs
+++ b/src/NATS.Client.ObjectStore/Internal/Encoder.cs
@@ -1,4 +1,7 @@
using System.Buffers;
+#if NET9_0_OR_GREATER
+using System.Buffers.Text;
+#endif
using System.Security.Cryptography;
namespace NATS.Client.ObjectStore.Internal;
@@ -18,14 +21,15 @@ internal static class Base64UrlEncoder
private const char Base64UrlCharacter62 = '-';
private const char Base64UrlCharacter63 = '_';
- ///
- /// Encoding table
- ///
- private static readonly char[] SBase64Table = new[]
+#if !NET9_0_OR_GREATER
+ // base64url alphabet (the 63rd/64th entries are '-'/'_'), so the table encodes url-safe directly.
+ private static readonly char[] SBase64Table =
{
- 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4',
- '5', '6', '7', '8', '9', Base64UrlCharacter62, Base64UrlCharacter63,
+ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
+ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
+ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', Base64UrlCharacter62, Base64UrlCharacter63,
};
+#endif
public static string Sha256(ReadOnlySpan value)
{
@@ -59,83 +63,115 @@ public static string Encode(string arg)
}
///
- /// Converts a subset of an array of 8-bit unsigned integers to its equivalent string representation which is encoded with base-64-url digits. Parameters specify
- /// the subset as an offset in the input array, and the number of elements in the array to convert.
+ /// Converts the bytes to their equivalent base64url string representation.
///
- /// An array of 8-bit unsigned integers.
- /// Remove padding
- /// The string representation in base 64 url encoding of length elements of inArray, starting at position offset.
- /// 'inArray' is null.
- /// offset or length is negative OR offset plus length is greater than the length of inArray.
+ /// The bytes to encode.
+ /// Remove padding.
+ /// The base64url encoding of the bytes.
public static string Encode(Span inArray, bool raw = false)
{
- var offset = 0;
var length = inArray.Length;
-
if (length == 0)
return string.Empty;
- var lengthMod3 = length % 3;
- var limit = length - lengthMod3;
- var output = new char[(length + 2) / 3 * 4];
- var table = SBase64Table;
- int i, j = 0;
+ var base64Length = (length + 2) / 3 * 4;
+#if NET9_0_OR_GREATER
+ // Base64Url encodes url-safe in a single pass (no separate swap). It omits padding, so for
+ // the default (raw == false) the trailing '=' is appended to match base64.URLEncoding.
+ char[]? rented = null;
+ var chars = base64Length <= 512
+ ? stackalloc char[base64Length]
+ : (rented = ArrayPool.Shared.Rent(base64Length)).AsSpan(0, base64Length);
+ try
+ {
+ var written = Base64Url.EncodeToChars(inArray, chars);
+ if (raw)
+ return new string(chars.Slice(0, written));
- // takes 3 bytes from inArray and insert 4 bytes into output
- for (i = offset; i < limit; i += 3)
+ chars.Slice(written, base64Length - written).Fill(Base64PadCharacter);
+ return new string(chars.Slice(0, base64Length));
+ }
+ finally
{
- var d0 = inArray[i];
- var d1 = inArray[i + 1];
- var d2 = inArray[i + 2];
-
- output[j + 0] = table[d0 >> 2];
- output[j + 1] = table[((d0 & 0x03) << 4) | (d1 >> 4)];
- output[j + 2] = table[((d1 & 0x0f) << 2) | (d2 >> 6)];
- output[j + 3] = table[d2 & 0x3f];
- j += 4;
+ if (rented != null)
+ ArrayPool.Shared.Return(rented);
}
-
- // Where we left off before
- i = limit;
-
- switch (lengthMod3)
+#else
+ // Pre-net9 there is no url-safe base64 encoder, so use the lookup table (which maps directly
+ // to the url-safe alphabet). Encode into a stack/pooled buffer with an unsafe pointer loop
+ // (no bounds checks), so only the result string is allocated.
+ char[]? rented = null;
+ var chars = base64Length <= 512
+ ? stackalloc char[base64Length]
+ : (rented = ArrayPool.Shared.Rent(base64Length)).AsSpan(0, base64Length);
+ try
{
- case 2:
- {
- var d0 = inArray[i];
- var d1 = inArray[i + 1];
-
- output[j + 0] = table[d0 >> 2];
- output[j + 1] = table[((d0 & 0x03) << 4) | (d1 >> 4)];
- output[j + 2] = table[(d1 & 0x0f) << 2];
- j += 3;
- }
-
- break;
+ var lengthMod3 = length % 3;
+ var limit = length - lengthMod3;
+ var j = 0;
- case 1:
+ unsafe
{
- var d0 = inArray[i];
+ // Pin the table and output and index them without bounds checks; inArray stays a
+ // checked span (its accesses are bounded by limit, derived from its own length).
+ fixed (char* dst = chars, tbl = SBase64Table)
+ {
+ // Each 3 input bytes map to 4 output chars.
+ for (var i = 0; i < limit; i += 3)
+ {
+ int d0 = inArray[i];
+ int d1 = inArray[i + 1];
+ int d2 = inArray[i + 2];
+
+ dst[j + 0] = tbl[d0 >> 2];
+ dst[j + 1] = tbl[((d0 & 0x03) << 4) | (d1 >> 4)];
+ dst[j + 2] = tbl[((d1 & 0x0f) << 2) | (d2 >> 6)];
+ dst[j + 3] = tbl[d2 & 0x3f];
+ j += 4;
+ }
- output[j + 0] = table[d0 >> 2];
- output[j + 1] = table[(d0 & 0x03) << 4];
- j += 2;
- }
+ switch (lengthMod3)
+ {
+ case 2:
+ {
+ int d0 = inArray[limit];
+ int d1 = inArray[limit + 1];
+ dst[j + 0] = tbl[d0 >> 2];
+ dst[j + 1] = tbl[((d0 & 0x03) << 4) | (d1 >> 4)];
+ dst[j + 2] = tbl[(d1 & 0x0f) << 2];
+ j += 3;
+ }
+
+ break;
+
+ case 1:
+ {
+ int d0 = inArray[limit];
+ dst[j + 0] = tbl[d0 >> 2];
+ dst[j + 1] = tbl[(d0 & 0x03) << 4];
+ j += 2;
+ }
+
+ break;
+ }
- break;
+ if (!raw)
+ {
+ for (var k = j; k < base64Length; k++)
+ dst[k] = Base64PadCharacter;
+ j = base64Length;
+ }
- // default or case 0: no further operations are needed.
+ return new string(dst, 0, j);
+ }
+ }
}
-
- if (raw)
- return new string(output, 0, j);
-
- for (var k = j; k < output.Length; k++)
+ finally
{
- output[k] = Base64PadCharacter;
+ if (rented != null)
+ ArrayPool.Shared.Return(rented);
}
-
- return new string(output);
+#endif
}
///
diff --git a/src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj b/src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj
index 7babd0d46..2b5b8de0d 100644
--- a/src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj
+++ b/src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj
@@ -9,6 +9,8 @@
+
+
diff --git a/src/NATS.Client.ObjectStore/NatsObjStore.cs b/src/NATS.Client.ObjectStore/NatsObjStore.cs
index befdca53c..c9d32d0dd 100644
--- a/src/NATS.Client.ObjectStore/NatsObjStore.cs
+++ b/src/NATS.Client.ObjectStore/NatsObjStore.cs
@@ -96,8 +96,8 @@ public async ValueTask GetAsync(string key, Stream stream, bool
{
// We have to make sure to carry on consuming the channel to avoid any blocking:
// e.g. if the channel is full, we would be blocking the reads off the socket (this was intentionally
- // done ot avoid bloating the memory with a large backlog of messages or dropping messages at this level
- // and signal the server that we are a slow consumer); then when we make an request-reply API call to
+ // done to avoid bloating the memory with a large backlog of messages or dropping messages at this level
+ // and signal the server that we are a slow consumer); then when we make a request-reply API call to
// delete the consumer, the socket would be blocked trying to send the response back to us; so we need to
// keep consuming the channel to avoid this.
if (pushConsumer.IsDone)
@@ -127,7 +127,10 @@ public async ValueTask GetAsync(string key, Stream stream, bool
digest = Base64UrlEncoder.Encode(sha256.Hash);
}
- if ($"SHA-256={digest}" != info.Digest)
+ const string digestPrefix = "SHA-256=";
+ if (info.Digest == null
+ || info.Digest.StartsWith(digestPrefix, StringComparison.Ordinal) == false
+ || info.Digest.AsSpan(digestPrefix.Length).SequenceEqual(digest.AsSpan()) == false)
{
throw new NatsObjException("SHA-256 digest mismatch");
}
diff --git a/tests/NATS.Client.ObjectStore.Tests/Base64UrlEncoderTest.cs b/tests/NATS.Client.ObjectStore.Encoder.Tests/Base64UrlEncoderTest.cs
similarity index 71%
rename from tests/NATS.Client.ObjectStore.Tests/Base64UrlEncoderTest.cs
rename to tests/NATS.Client.ObjectStore.Encoder.Tests/Base64UrlEncoderTest.cs
index f349e8c7a..922210ca4 100644
--- a/tests/NATS.Client.ObjectStore.Tests/Base64UrlEncoderTest.cs
+++ b/tests/NATS.Client.ObjectStore.Encoder.Tests/Base64UrlEncoderTest.cs
@@ -1,7 +1,8 @@
+using System.Security.Cryptography;
using System.Text;
using NATS.Client.ObjectStore.Internal;
-namespace NATS.Client.ObjectStore.Tests;
+namespace NATS.Client.ObjectStore.Encoder.Tests;
public class Base64UrlEncoderTest
{
@@ -37,6 +38,25 @@ public void Decoding_test(string input)
_output.WriteLine($">>{decoded}<<");
}
+ [Theory]
+ [InlineData("")]
+ [InlineData("Hello World!")]
+ [InlineData("The quick brown fox jumps over the lazy dog")]
+ public void Sha256_test(string input)
+ {
+ var data = Encoding.UTF8.GetBytes(input);
+ var actual = Base64UrlEncoder.Sha256(data);
+
+ // Independent reference: hash, then base64url-encode the 32-byte digest keeping the '=' pad.
+ using var sha256 = SHA256.Create();
+ var expected = Convert.ToBase64String(sha256.ComputeHash(data))
+ .Replace('+', '-')
+ .Replace('/', '_');
+
+ Assert.Equal(expected, actual);
+ _output.WriteLine($">>{actual}<<");
+ }
+
private string Encode(string input, bool raw = false)
{
var base64String = Convert.ToBase64String(Encoding.UTF8.GetBytes(input));
diff --git a/tests/NATS.Client.ObjectStore.Encoder.Tests/NATS.Client.ObjectStore.Encoder.Tests.csproj b/tests/NATS.Client.ObjectStore.Encoder.Tests/NATS.Client.ObjectStore.Encoder.Tests.csproj
new file mode 100644
index 000000000..19430987f
--- /dev/null
+++ b/tests/NATS.Client.ObjectStore.Encoder.Tests/NATS.Client.ObjectStore.Encoder.Tests.csproj
@@ -0,0 +1,43 @@
+
+
+
+
+ net6.0;net8.0;net10.0
+ $(TargetFrameworks);net481
+ any;win-x86
+ enable
+ enable
+ latest
+ false
+
+ true
+ $(MSBuildProjectDirectory)\..\xunit.runsettings
+ false
+ true
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+