diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 529fb0cb7..0111fda6c 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -52,7 +52,7 @@ jobs:
- name: Begin scan
if: env.SONAR_TOKEN != null && env.SONAR_TOKEN != ''
- run: dotnet sonarscanner begin /k:"GenHTTP" /d:sonar.token="$SONAR_TOKEN" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.exclusions="**/bin/**/*,**/obj/**/*,**/*.css,**/*.js,**/*.html" /o:"kaliumhexacyanoferrat" /k:"GenHTTP" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.branch.name="${GITHUB_REF##*/}" /d:sonar.dotnet.excludeTestProjects=true
+ run: dotnet sonarscanner begin /k:"GenHTTP" /d:sonar.token="$SONAR_TOKEN" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.exclusions="**/bin/**/*,**/obj/**/*,**/*.css,**/*.js,**/*.html" /d:sonar.coverage.exclusions="**/Engine/Ioxide/Tls/TlsDuplexPipe.cs" /o:"kaliumhexacyanoferrat" /k:"GenHTTP" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.branch.name="${GITHUB_REF##*/}" /d:sonar.dotnet.excludeTestProjects=true
- name: Build project
run: dotnet build GenHTTP.slnx -c Release
diff --git a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj
index 2ee39b7cb..596800623 100644
--- a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj
+++ b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj
@@ -17,4 +17,8 @@
+
+
+
+
diff --git a/Modules/Websockets/Utils/PooledMemoryOwner.cs b/Modules/Websockets/Utils/PooledMemoryOwner.cs
deleted file mode 100644
index 4b799e910..000000000
--- a/Modules/Websockets/Utils/PooledMemoryOwner.cs
+++ /dev/null
@@ -1,33 +0,0 @@
-using System.Buffers;
-
-namespace GenHTTP.Modules.Websockets.Utils;
-
-public sealed class PooledMemoryOwner : IMemoryOwner
-{
- private readonly ArrayPool _pool;
-
- private byte[]? _buffer;
-
- public PooledMemoryOwner(byte[] buffer, int length, ArrayPool pool)
- {
- _buffer = buffer;
-
- Memory = new Memory(_buffer, 0, length);
-
- _pool = pool;
- }
-
- public Memory Memory { get; }
-
- public void Dispose()
- {
- if (_buffer == null)
- {
- return;
- }
-
- _pool.Return(_buffer);
-
- _buffer = null;
- }
-}
\ No newline at end of file
diff --git a/Testing/Acceptance/Engine/Body/ChunkedBodyStreamTests.cs b/Testing/Acceptance/Engine/Body/ChunkedBodyStreamTests.cs
index 9c69a4b82..5384a79fc 100644
--- a/Testing/Acceptance/Engine/Body/ChunkedBodyStreamTests.cs
+++ b/Testing/Acceptance/Engine/Body/ChunkedBodyStreamTests.cs
@@ -48,6 +48,73 @@ public void TestBasics()
Assert.ThrowsExactly(() => stream.SetLength(0));
Assert.ThrowsExactly(() => stream.Write([], 0, 0));
Assert.ThrowsExactly(() => _ = stream.Length);
+
+ Assert.ThrowsExactly(() => _ = stream.Position);
+ Assert.ThrowsExactly(() => stream.Position = 0);
+ }
+
+ [TestMethod]
+ public async Task TestReadAsyncByteArrayOverload()
+ {
+ var stream = await CreateAsync("5\r\nHello\r\n0\r\n\r\n");
+
+ var buffer = new byte[5];
+ var read = await stream.ReadAsync(buffer, 0, buffer.Length, CancellationToken.None);
+
+ Assert.AreEqual(5, read);
+ Assert.AreEqual("Hello", Encoding.ASCII.GetString(buffer));
+ }
+
+ [TestMethod]
+ public async Task TestReadIntoEmptyBufferReturnsZero()
+ {
+ var stream = await CreateAsync("5\r\nHello\r\n0\r\n\r\n");
+
+ Assert.AreEqual(0, await stream.ReadAsync(Memory.Empty));
+ }
+
+ [TestMethod]
+ public async Task TestReadOnEmptyCompletedPipeReturnsZero()
+ {
+ var stream = await CreateAsync("");
+
+ Assert.AreEqual(0, await stream.ReadAsync(new byte[16]));
+ }
+
+ [TestMethod]
+ public async Task TestTruncatedChunkThrows()
+ {
+ var stream = await CreateAsync("5\r\nHel");
+
+#pragma warning disable CA2022 // expected to throw before any byte count is returned
+ await Assert.ThrowsExactlyAsync(async () => await stream.ReadAsync(new byte[16]));
+#pragma warning restore CA2022
+ }
+
+ [TestMethod]
+ public async Task TestDrainOnEmptyCompletedPipe()
+ {
+ var stream = await CreateAsync("");
+
+ await stream.DrainAsync();
+ }
+
+ [TestMethod]
+ public async Task TestDrainOnTruncatedChunkReturnsWithoutThrowing()
+ {
+ var stream = await CreateAsync("5\r\nHel");
+
+ await stream.DrainAsync();
+ }
+
+ private static async Task CreateAsync(string chunkedBody)
+ {
+ var pipe = new Pipe();
+
+ await pipe.Writer.WriteAsync(Encoding.ASCII.GetBytes(chunkedBody));
+ await pipe.Writer.CompleteAsync();
+
+ return new ChunkedBodyStream(pipe.Reader);
}
}
diff --git a/Testing/Acceptance/Engine/Body/DrainBodyTests.cs b/Testing/Acceptance/Engine/Body/DrainBodyTests.cs
index e5dc6b5bf..895778847 100644
--- a/Testing/Acceptance/Engine/Body/DrainBodyTests.cs
+++ b/Testing/Acceptance/Engine/Body/DrainBodyTests.cs
@@ -146,6 +146,33 @@ public async Task TestPartialRead(TestEngine engine)
Assert.AreEqual(3, handler.RequestCount);
}
+ [TestMethod]
+ [MultiEngineTest]
+ public async Task TestPartialReadChunked(TestEngine engine)
+ {
+ var handler = new PartialReadHandler(bytesToRead: 4);
+
+ await using var runner = await TestHost.RunAsync(handler.Wrap(), engine: engine);
+
+ using var client = TestHost.GetClient();
+
+ for (var i = 0; i < 3; i++)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Post, runner.GetUrl())
+ {
+ Content = new StreamContent(new MemoryStream("Hello, body!"u8.ToArray()))
+ };
+
+ request.Headers.TransferEncodingChunked = true;
+
+ using var response = await client.SendAsync(request);
+
+ await response.AssertStatusAsync(HttpStatusCode.OK);
+ }
+
+ Assert.AreEqual(3, handler.RequestCount);
+ }
+
#endregion
#region Supporting types
diff --git a/Testing/Acceptance/Engine/HostTests.cs b/Testing/Acceptance/Engine/HostTests.cs
index 7b275d5fb..55d9a16aa 100644
--- a/Testing/Acceptance/Engine/HostTests.cs
+++ b/Testing/Acceptance/Engine/HostTests.cs
@@ -1,4 +1,5 @@
using System.Net;
+using GenHTTP.Engine.Internal;
using GenHTTP.Modules.Layouting;
namespace GenHTTP.Testing.Acceptance.Engine;
@@ -7,6 +8,24 @@ namespace GenHTTP.Testing.Acceptance.Engine;
public sealed class HostTests
{
+ [TestMethod]
+ public void TestPortZeroThrows()
+ {
+ var host = Host.Create();
+
+ Assert.ThrowsExactly(() => host.Port(0));
+ }
+
+ [TestMethod]
+ public async Task TestRunAsyncReturnsErrorCodeWithoutHandler()
+ {
+ var host = Host.Create();
+
+ var exitCode = await host.RunAsync();
+
+ Assert.AreEqual(-1, exitCode);
+ }
+
[TestMethod]
[MultiEngineTest]
public async Task TestStart(TestEngine engine)
diff --git a/Testing/Acceptance/Engine/Ioxide/PipeWriterStreamTests.cs b/Testing/Acceptance/Engine/Ioxide/PipeWriterStreamTests.cs
new file mode 100644
index 000000000..222279c06
--- /dev/null
+++ b/Testing/Acceptance/Engine/Ioxide/PipeWriterStreamTests.cs
@@ -0,0 +1,68 @@
+#if NET11_0_OR_GREATER
+
+using System.Buffers;
+using System.IO.Pipelines;
+using System.Text;
+
+using GenHTTP.Engine.Ioxide.Protocol;
+
+namespace GenHTTP.Testing.Acceptance.Engine.Ioxide;
+
+[TestClass]
+public sealed class PipeWriterStreamTests
+{
+
+ [TestMethod]
+ public void TestBasics()
+ {
+ var pipe = new Pipe();
+
+ var stream = new PipeWriterStream(new ArrayBufferWriter(), pipe.Writer);
+
+ Assert.IsFalse(stream.CanRead);
+ Assert.IsFalse(stream.CanSeek);
+ Assert.IsTrue(stream.CanWrite);
+
+ Assert.ThrowsExactly(() => _ = stream.Length);
+ Assert.ThrowsExactly(() => _ = stream.Position);
+ Assert.ThrowsExactly(() => stream.Position = 0);
+
+ Assert.ThrowsExactly(() => stream.Read([], 0, 1));
+ Assert.ThrowsExactly(() => stream.Seek(0, SeekOrigin.Begin));
+ Assert.ThrowsExactly(() => stream.SetLength(0));
+
+ stream.Flush();
+ }
+
+ [TestMethod]
+ public void TestWriteByte()
+ {
+ var writer = new ArrayBufferWriter();
+
+ var stream = new PipeWriterStream(writer, new Pipe().Writer);
+
+ stream.WriteByte((byte)'H');
+ stream.WriteByte((byte)'i');
+
+ Assert.AreEqual("Hi", Encoding.ASCII.GetString(writer.WrittenSpan));
+ }
+
+ [TestMethod]
+ public async Task TestFlushAsyncDrainsUnderlyingPipe()
+ {
+ var pipe = new Pipe();
+
+ var stream = new PipeWriterStream(pipe.Writer, pipe.Writer);
+
+ await stream.WriteAsync("Hello"u8.ToArray());
+
+ await stream.FlushAsync();
+
+ var read = await pipe.Reader.ReadAsync();
+
+ Assert.AreEqual("Hello", Encoding.ASCII.GetString(read.Buffer.ToArray()));
+ }
+
+}
+
+#endif
diff --git a/Testing/Acceptance/Engine/Kestrel/ProtocolTests.cs b/Testing/Acceptance/Engine/Kestrel/ProtocolTests.cs
index e15bdb6a8..b370e5070 100644
--- a/Testing/Acceptance/Engine/Kestrel/ProtocolTests.cs
+++ b/Testing/Acceptance/Engine/Kestrel/ProtocolTests.cs
@@ -40,6 +40,37 @@ public async Task TestHttp2And3()
}
}
+ [TestMethod]
+ public async Task TestHttpsOnSpecificAddress()
+ {
+ if (!Engines.KestrelEnabled()) return;
+
+ var logic = Inline.Create().Get(() => "Hello");
+
+ var certificate = await Utilities.Security.GetCertificateAsync();
+
+ var runner = new TestHost(logic.Build(), engine: TestEngine.Kestrel);
+
+ var port = TestHost.NextPort();
+
+ runner.Host.Bind(IPAddress.Loopback, (ushort)port, certificate);
+
+ await runner.StartAsync();
+
+ try
+ {
+ using var client = TestHost.GetClient(ignoreSecurityErrors: true);
+
+ using var response = await client.GetAsync($"https://localhost:{port}");
+
+ await response.AssertStatusAsync(HttpStatusCode.OK);
+ }
+ finally
+ {
+ await runner.DisposeAsync();
+ }
+ }
+
#region Helpers
private static HttpClient GetClient()
diff --git a/Testing/Acceptance/Engine/RequestPropertyTest.cs b/Testing/Acceptance/Engine/RequestPropertyTest.cs
index d29e3866b..be28e6748 100644
--- a/Testing/Acceptance/Engine/RequestPropertyTest.cs
+++ b/Testing/Acceptance/Engine/RequestPropertyTest.cs
@@ -1,8 +1,12 @@
using System.Net;
+using GenHTTP.Api.Infrastructure;
using GenHTTP.Api.Protocol;
+using GenHTTP.Engine.Shared.Types;
using GenHTTP.Modules.Functional;
+using NSubstitute;
+
namespace GenHTTP.Testing.Acceptance.Engine;
[TestClass]
@@ -35,4 +39,56 @@ public async Task TestRequestProperties(TestEngine engine)
await response.AssertStatusAsync(HttpStatusCode.OK);
}
+ [TestMethod]
+ [MultiEngineTest]
+ public async Task TestGetBodyTwiceThrows(TestEngine engine)
+ {
+ var app = Inline.Create().Get((IRequest request) =>
+ {
+ request.GetBody();
+
+ Assert.ThrowsExactly(() => request.GetBody());
+
+ return true;
+ });
+
+ await using var host = await TestHost.RunAsync(app, engine: engine);
+
+ using var response = await host.GetResponseAsync();
+
+ await response.AssertStatusAsync(HttpStatusCode.OK);
+ }
+
+ [TestMethod]
+ [MultiEngineTest]
+ public async Task TestHeaderAccessibleAfterBodyLoaded(TestEngine engine)
+ {
+ var app = Inline.Create().Get((IRequest request) =>
+ {
+ request.GetBody();
+
+ return request.Header.Target.AsString(decode: false);
+ });
+
+ await using var host = await TestHost.RunAsync(app, engine: engine);
+
+ using var response = await host.GetResponseAsync();
+
+ await response.AssertStatusAsync(HttpStatusCode.OK);
+ }
+
+ [TestMethod]
+ public void TestApplyWithoutEndpoint()
+ {
+ var request = new Request();
+
+ var server = Substitute.For();
+
+ request.Apply(server);
+
+ Assert.AreSame(server, request.Server);
+ Assert.IsNotNull(request.Header);
+ Assert.IsNotNull(request.Properties);
+ }
+
}
diff --git a/Testing/Acceptance/GenHTTP.Testing.Acceptance.csproj b/Testing/Acceptance/GenHTTP.Testing.Acceptance.csproj
index 9a603e821..c0a97d632 100644
--- a/Testing/Acceptance/GenHTTP.Testing.Acceptance.csproj
+++ b/Testing/Acceptance/GenHTTP.Testing.Acceptance.csproj
@@ -44,6 +44,8 @@
+
+
@@ -78,6 +80,10 @@
+
+
+
diff --git a/Testing/Acceptance/Generators/MemoryViewGeneratorTests.cs b/Testing/Acceptance/Generators/MemoryViewGeneratorTests.cs
new file mode 100644
index 000000000..2ce4eaab1
--- /dev/null
+++ b/Testing/Acceptance/Generators/MemoryViewGeneratorTests.cs
@@ -0,0 +1,134 @@
+using System.Collections.Immutable;
+using System.Text.RegularExpressions;
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+
+using GenHTTP.Generators.MemoryView;
+
+namespace GenHTTP.Testing.Acceptance.Generators;
+
+[TestClass]
+public sealed class MemoryViewGeneratorTests
+{
+
+ #region Supporting infrastructure
+
+ private static (ImmutableArray Sources, ImmutableArray Diagnostics) Generate(string source)
+ {
+ var references = AppDomain.CurrentDomain
+ .GetAssemblies()
+ .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location))
+ .Select(a => (MetadataReference)MetadataReference.CreateFromFile(a.Location));
+
+ var compilation = CSharpCompilation.Create(
+ assemblyName: "MemoryViewGeneratorTests.Generated",
+ syntaxTrees: [CSharpSyntaxTree.ParseText(source)],
+ references: references,
+ options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
+ );
+
+ GeneratorDriver driver = CSharpGeneratorDriver.Create(new MemoryViewGenerator());
+
+ driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out _);
+
+ var result = driver.GetRunResult().Results.Single();
+
+ return (result.GeneratedSources, outputCompilation.GetDiagnostics());
+ }
+
+ private static void AssertNoErrors(ImmutableArray diagnostics)
+ {
+ var errors = diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).ToList();
+
+ Assert.IsTrue(errors.Count == 0, string.Join(Environment.NewLine, errors));
+ }
+
+ #endregion
+
+ #region Tests
+
+ [TestMethod]
+ public void TestStructInNamespaceIsGenerated()
+ {
+ const string source = """
+ namespace MyApp;
+
+ [GenHTTP.Api.MemoryViewAttribute]
+ public readonly partial struct MyView { }
+ """;
+
+ var (sources, diagnostics) = Generate(source);
+
+ Assert.AreEqual(1, sources.Length);
+ Assert.AreEqual("MyApp.MyView.MemoryView.g.cs", sources[0].HintName);
+
+ var text = sources[0].SourceText.ToString();
+
+ StringAssert.Contains(text, "namespace MyApp;");
+ StringAssert.Contains(text, "public readonly partial struct MyView");
+ StringAssert.Contains(text, "public bool Equals(MyView other)");
+
+ AssertNoErrors(diagnostics);
+ }
+
+ [TestMethod]
+ public void TestStructInGlobalNamespaceIsGenerated()
+ {
+ const string source = """
+ [GenHTTP.Api.MemoryViewAttribute]
+ public readonly partial struct MyGlobalView { }
+ """;
+
+ var (sources, diagnostics) = Generate(source);
+
+ Assert.AreEqual(1, sources.Length);
+ Assert.AreEqual("MyGlobalView.MemoryView.g.cs", sources[0].HintName);
+
+ StringAssert.DoesNotMatch(sources[0].SourceText.ToString(), new Regex("^namespace ", RegexOptions.Multiline));
+
+ AssertNoErrors(diagnostics);
+ }
+
+ [TestMethod]
+ public void TestStructWithoutAttributeIsIgnored()
+ {
+ const string source = """
+ namespace MyApp;
+
+ public readonly partial struct MyView { }
+ """;
+
+ var (sources, _) = Generate(source);
+
+ Assert.AreEqual(0, sources.Length);
+ }
+
+ [TestMethod]
+ public void TestMultipleStructsProduceIndependentSources()
+ {
+ const string source = """
+ namespace MyApp;
+
+ [GenHTTP.Api.MemoryViewAttribute]
+ public readonly partial struct First { }
+
+ [GenHTTP.Api.MemoryViewAttribute]
+ public readonly partial struct Second { }
+ """;
+
+ var (sources, diagnostics) = Generate(source);
+
+ Assert.AreEqual(2, sources.Length);
+
+ CollectionAssert.AreEquivalent(
+ new[] { "MyApp.First.MemoryView.g.cs", "MyApp.Second.MemoryView.g.cs" },
+ sources.Select(s => s.HintName).ToList()
+ );
+
+ AssertNoErrors(diagnostics);
+ }
+
+ #endregion
+
+}
diff --git a/Testing/Acceptance/Modules/Authentication/BearerAuthenticationTests.cs b/Testing/Acceptance/Modules/Authentication/BearerAuthenticationTests.cs
index d39794b05..6792f5b28 100644
--- a/Testing/Acceptance/Modules/Authentication/BearerAuthenticationTests.cs
+++ b/Testing/Acceptance/Modules/Authentication/BearerAuthenticationTests.cs
@@ -1,11 +1,17 @@
-using System.Net;
+using System.IdentityModel.Tokens.Jwt;
+using System.Net;
using System.Net.Http.Headers;
+using System.Security.Cryptography;
+
+using Microsoft.IdentityModel.Tokens;
+
using GenHTTP.Api.Content;
using GenHTTP.Api.Content.Authentication;
using GenHTTP.Api.Protocol;
using GenHTTP.Modules.Authentication;
using GenHTTP.Modules.Authentication.Bearer;
using GenHTTP.Modules.Functional;
+using GenHTTP.Modules.Layouting;
namespace GenHTTP.Testing.Acceptance.Modules.Authentication;
@@ -108,6 +114,99 @@ public async Task TestMalformedToken(TestEngine engine)
await response.AssertStatusAsync(HttpStatusCode.BadRequest);
}
+ [TestMethod]
+ [MultiEngineTest]
+ public async Task TestIssuerFetchesRealSigningKeys(TestEngine engine)
+ {
+ using var rsa = RSA.Create(2048);
+
+ await using var issuerHost = await CreateIssuerAsync(engine, rsa);
+
+ var issuer = issuerHost.GetUrl();
+
+ var auth = BearerAuthentication.Create().Issuer(issuer).AllowExpired();
+
+ var token = CreateToken(issuer, rsa);
+
+ using var response = await Execute(auth, engine, token);
+
+ await response.AssertStatusAsync(HttpStatusCode.OK);
+ }
+
+ [TestMethod]
+ [MultiEngineTest]
+ public async Task TestIssuerRejectsTokenSignedWithUnknownKey(TestEngine engine)
+ {
+ using var rsa = RSA.Create(2048);
+ using var otherRsa = RSA.Create(2048);
+
+ await using var issuerHost = await CreateIssuerAsync(engine, rsa);
+
+ var issuer = issuerHost.GetUrl();
+
+ var auth = BearerAuthentication.Create().Issuer(issuer);
+
+ var token = CreateToken(issuer, otherRsa);
+
+ using var response = await Execute(auth, engine, token);
+
+ await response.AssertStatusAsync(HttpStatusCode.Unauthorized);
+ }
+
+ [TestMethod]
+ [MultiEngineTest]
+ public async Task TestUnreachableIssuerConfigYieldsInternalServerError(TestEngine engine)
+ {
+ // no ".well-known/openid-configuration" route configured -> the issuer 404s
+ await using var issuerHost = await TestHost.RunAsync(Layout.Create(), engine: engine);
+
+ var issuer = issuerHost.GetUrl();
+
+ var auth = BearerAuthentication.Create().Issuer(issuer).AllowExpired();
+
+ using var rsa = RSA.Create(2048);
+
+ var token = CreateToken(issuer, rsa);
+
+ using var response = await Execute(auth, engine, token);
+
+ await response.AssertStatusAsync(HttpStatusCode.InternalServerError);
+ }
+
+ private static async Task CreateIssuerAsync(TestEngine engine, RSA rsa)
+ {
+ var securityKey = new RsaSecurityKey(rsa) { KeyId = "test-key" };
+ var jwk = JsonWebKeyConverter.ConvertFromRSASecurityKey(securityKey);
+
+ var jwksJson = $$"""{"keys":[{"kty":"RSA","use":"sig","kid":"{{jwk.Kid}}","n":"{{jwk.N}}","e":"{{jwk.E}}","alg":"RS256"}]}""";
+
+ // the handler references the host's own port (for jwks_uri), so it's built after the
+ // port is known but before the not-yet-started host is actually started
+ var issuerHost = new TestHost(Layout.Create().Build(), engine: engine);
+
+ var configJson = $$"""{"jwks_uri":"{{issuerHost.GetUrl("/jwks")}}"}""";
+
+ issuerHost.Host.Handler(Layout.Create()
+ .Add(".well-known", Layout.Create()
+ .Add("openid-configuration", Inline.Create().Get(() => configJson)))
+ .Add("jwks", Inline.Create().Get(() => jwksJson)));
+
+ await issuerHost.StartAsync();
+
+ return issuerHost;
+ }
+
+ private static string CreateToken(string issuer, RSA rsa)
+ {
+ var handler = new JwtSecurityTokenHandler();
+
+ var credentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256);
+
+ var token = new JwtSecurityToken(issuer: issuer, claims: [], signingCredentials: credentials);
+
+ return handler.WriteToken(token);
+ }
+
private static async Task Execute(BearerAuthenticationConcernBuilder builder, TestEngine engine, string? token = null)
{
var handler = Inline.Create()
diff --git a/Testing/Acceptance/Modules/IO/WriterStreamAdapterTests.cs b/Testing/Acceptance/Modules/IO/WriterStreamAdapterTests.cs
index c1c67d0c4..a143962f1 100644
--- a/Testing/Acceptance/Modules/IO/WriterStreamAdapterTests.cs
+++ b/Testing/Acceptance/Modules/IO/WriterStreamAdapterTests.cs
@@ -37,6 +37,46 @@ public void TestBasics()
Assert.ThrowsExactly(() => stream.Seek(0, SeekOrigin.Begin));
Assert.ThrowsExactly(() => stream.SetLength(0));
Assert.ThrowsExactly(() => _ = stream.Length);
+
+ Assert.ThrowsExactly(() => _ = stream.Position);
+ Assert.ThrowsExactly(() => stream.Position = 0);
+
+ Assert.ThrowsExactly(() => stream.Read(Span.Empty));
+
+ stream.Flush();
+ }
+
+ [TestMethod]
+ public async Task TestReadAsyncNotSupported()
+ {
+ using var stream = new WriterStreamAdapter(new ArrayBufferWriter());
+
+#pragma warning disable CA2022 // both calls are expected to throw before any byte count is returned
+ await Assert.ThrowsExactlyAsync(async () => await stream.ReadAsync(Memory.Empty));
+ await Assert.ThrowsExactlyAsync(async () => await stream.ReadAsync([], 0, 1, CancellationToken.None));
+#pragma warning restore CA2022
+ }
+
+ [TestMethod]
+ public async Task TestWriteAsyncByteArrayOverload()
+ {
+ var writer = new ArrayBufferWriter();
+
+ using var stream = new WriterStreamAdapter(writer);
+
+ await stream.WriteAsync("Hi"u8.ToArray(), 0, 2, CancellationToken.None);
+
+ Assert.AreEqual("Hi", Encoding.ASCII.GetString(writer.WrittenSpan));
+
+ await Assert.ThrowsExactlyAsync(async () => await stream.WriteAsync([], 0, 0, new CancellationToken(true)));
+ }
+
+ [TestMethod]
+ public async Task TestFlushAsyncCancelled()
+ {
+ using var stream = new WriterStreamAdapter(new ArrayBufferWriter());
+
+ await Assert.ThrowsExactlyAsync(async () => await stream.FlushAsync(new CancellationToken(true)));
}
}
diff --git a/Testing/Acceptance/Modules/IoxideFiles/IoxideFilesTests.cs b/Testing/Acceptance/Modules/IoxideFiles/IoxideFilesTests.cs
index 87c3c036f..20a41a663 100644
--- a/Testing/Acceptance/Modules/IoxideFiles/IoxideFilesTests.cs
+++ b/Testing/Acceptance/Modules/IoxideFiles/IoxideFilesTests.cs
@@ -185,6 +185,58 @@ await RunAsync(async host =>
});
}
+ [TestMethod]
+ public async Task TestLargeFileIsServedFromDisk()
+ {
+ if (!Engines.IoxideEnabled()) return;
+
+ var dir = Directory.CreateTempSubdirectory();
+
+ // AssetCache.DefaultMaxCachedFileBytes is 256 KB - go over it so the asset is read off the ring
+ // instead of being served from the baked in-memory response.
+ var content = string.Concat(Enumerable.Repeat("0123456789", 30_000));
+
+ await File.WriteAllTextAsync(Path.Combine(dir.FullName, "large.txt"), content);
+
+ var handler = IoxideFilesModule.From(dir.FullName);
+
+ await using var host = await TestHost.RunAsync(handler, engine: TestEngine.Ioxide);
+
+ for (var i = 0; i < 3; i++)
+ {
+ using var response = await host.GetResponseAsync("/large.txt");
+
+ await response.AssertStatusAsync(HttpStatusCode.OK);
+
+ Assert.AreEqual(content, await response.GetContentAsync());
+ }
+ }
+
+ [TestMethod]
+ public async Task TestChangedFileServesUpdatedContent()
+ {
+ if (!Engines.IoxideEnabled()) return;
+
+ var dir = Directory.CreateTempSubdirectory();
+
+ var file = Path.Combine(dir.FullName, "file.txt");
+
+ await File.WriteAllTextAsync(file, "This is root");
+
+ var handler = IoxideFilesModule.From(dir.FullName);
+
+ // Edited after the cache snapshot was taken, with a different length so IsFresh's size check fails.
+ await File.WriteAllTextAsync(file, "This is the updated content");
+
+ await using var host = await TestHost.RunAsync(handler, engine: TestEngine.Ioxide);
+
+ using var response = await host.GetResponseAsync("/file.txt");
+
+ await response.AssertStatusAsync(HttpStatusCode.OK);
+
+ Assert.AreEqual("This is the updated content", await response.GetContentAsync());
+ }
+
[TestMethod]
public void TestChaining()
{
diff --git a/Testing/Acceptance/Modules/ReverseProxy/ReverseProxyTests.cs b/Testing/Acceptance/Modules/ReverseProxy/ReverseProxyTests.cs
index ac7659127..e0a978afb 100644
--- a/Testing/Acceptance/Modules/ReverseProxy/ReverseProxyTests.cs
+++ b/Testing/Acceptance/Modules/ReverseProxy/ReverseProxyTests.cs
@@ -7,6 +7,8 @@
using GenHTTP.Modules.Layouting;
using GenHTTP.Modules.ReverseProxy;
+using GenHTTP.Testing.Acceptance.Utilities;
+
namespace GenHTTP.Testing.Acceptance.Modules.ReverseProxy;
[TestClass]
@@ -336,6 +338,91 @@ public async Task TestCompression(TestEngine engine)
Assert.AreEqual("br", response.GetContentHeader("Content-Encoding"));
}
+ [TestMethod]
+ [MultiEngineTest]
+ public async Task TestRedirectionToExternalHostIsNotRewritten(TestEngine engine)
+ {
+ await using var setup = await TestSetup.CreateAsync(engine, r =>
+ {
+ return r.Respond().Header("Location", "https://example.com/elsewhere").Status(ResponseStatus.TemporaryRedirect).Build();
+ });
+
+ using var redirected = await setup.Runner.GetResponseAsync("/");
+
+ Assert.AreEqual("https://example.com/elsewhere", redirected.GetHeader("Location"));
+ }
+
+ [TestMethod]
+ [MultiEngineTest]
+ public async Task TestRedirectionFromScopedMount(TestEngine engine)
+ {
+ await using var upstream = new TestHost(Layout.Create().Build(), false, engine: engine);
+
+ await upstream.Host.Handler(new ProxiedRouter(r =>
+ r.Respond().Header("Location", $"http://localhost:{upstream.Port}/target").Status(ResponseStatus.TemporaryRedirect).Build()))
+ .StartAsync();
+
+ var proxy = Proxy.Create().Upstream("http://localhost:" + upstream.Port);
+
+ await using var runner = await TestHost.RunAsync(Layout.Create().Add("api", proxy), engine: engine);
+
+ using var redirected = await runner.GetResponseAsync("/api/whatever");
+
+ var location = redirected.GetHeader("Location");
+
+ Assert.IsNotNull(location);
+ AssertX.Contains($"http://localhost:{runner.Port}", location);
+ AssertX.Contains("/target", location);
+ }
+
+ [TestMethod]
+ [MultiEngineTest]
+ public async Task TestForwardingByAddressIsRelayed(TestEngine engine)
+ {
+ await using var setup = await TestSetup.CreateAsync(engine, r =>
+ {
+ var header = r.Header.Headers.GetEntry("Forwarded");
+
+ Assert.IsNotNull(header);
+ AssertX.Contains("by=203.0.113.5", header);
+
+ return r.Respond().Content("Hello World!").Build();
+ });
+
+ var request = setup.Runner.GetRequest();
+ request.Headers.Add("Forwarded", "for=85.192.1.5; by=203.0.113.5; host=google.com");
+
+ using var response = await setup.Runner.GetResponseAsync(request);
+ Assert.AreEqual("Hello World!", await response.GetContentAsync());
+ }
+
+ [TestMethod]
+ [MultiEngineTest]
+ public async Task TestForwardingByAddressIsRelayedForIPv6(TestEngine engine)
+ {
+ await using var setup = await TestSetup.CreateAsync(engine, r =>
+ {
+ var header = r.Header.Headers.GetEntry("Forwarded");
+
+ Assert.IsNotNull(header);
+ AssertX.Contains("by=[2001:db8::1]", header);
+
+ return r.Respond().Content("Hello World!").Build();
+ });
+
+ var request = setup.Runner.GetRequest();
+ request.Headers.Add("Forwarded", "for=85.192.1.5; by=\"[2001:db8::1]\"; host=google.com");
+
+ using var response = await setup.Runner.GetResponseAsync(request);
+ Assert.AreEqual("Hello World!", await response.GetContentAsync());
+ }
+
+ [TestMethod]
+ public void TestChaining()
+ {
+ Chain.Works(Proxy.Create().Upstream("https://google.com"));
+ }
+
[TestMethod]
public void TestAdjustments()
{
diff --git a/Testing/Acceptance/Modules/ReverseProxy/WebsocketProxy/RawWebsocketConnectionTests.cs b/Testing/Acceptance/Modules/ReverseProxy/WebsocketProxy/RawWebsocketConnectionTests.cs
new file mode 100644
index 000000000..143b237c2
--- /dev/null
+++ b/Testing/Acceptance/Modules/ReverseProxy/WebsocketProxy/RawWebsocketConnectionTests.cs
@@ -0,0 +1,81 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+
+using GenHTTP.Api.Content;
+using GenHTTP.Api.Infrastructure;
+using GenHTTP.Api.Protocol;
+using GenHTTP.Engine.Shared.Types;
+using GenHTTP.Modules.ReverseProxy.Websocket;
+
+using NSubstitute;
+
+namespace GenHTTP.Testing.Acceptance.Modules.ReverseProxy.WebsocketProxy;
+
+[TestClass]
+public sealed class RawWebsocketConnectionTests
+{
+
+ [TestMethod]
+ public void TestInvalidUrlThrows()
+ {
+ Assert.ThrowsExactly(() => new RawWebsocketConnection("not a valid url"));
+ }
+
+ [TestMethod]
+ public void TestDefaultPortsResolveForKnownAndUnknownSchemes()
+ {
+ _ = new RawWebsocketConnection("http://example.com/");
+ _ = new RawWebsocketConnection("https://example.com/");
+ _ = new RawWebsocketConnection("ws://example.com/");
+ _ = new RawWebsocketConnection("wss://example.com/");
+ _ = new RawWebsocketConnection("ftp://example.com/"); // unrecognized scheme - falls back to port 0
+ }
+
+ [TestMethod]
+ public async Task TestUpgradeWithoutInitializeThrows()
+ {
+ var connection = new RawWebsocketConnection("ws://example.com/");
+
+ // Pipe is null before InitializeStream() runs, so the guard fires before the request is
+ // ever touched - passing null is safe here.
+ await Assert.ThrowsExactlyAsync(() => connection.TryUpgrade(null!));
+ }
+
+ [TestMethod]
+ public async Task TestUpstreamClosesBeforeHandshakeCompletes()
+ {
+ using var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+
+ var acceptTask = Task.Run(async () =>
+ {
+ using var socket = await listener.AcceptSocketAsync();
+
+ // Send a partial, incomplete handshake response, then close without the terminator.
+ await socket.SendAsync("HTTP/1.1 101 Switching"u8.ToArray(), SocketFlags.None);
+ socket.Shutdown(SocketShutdown.Send);
+
+ // Give the peer time to observe the clean FIN before we tear the socket down.
+ await Task.Delay(200);
+ });
+
+ await using var connection = new RawWebsocketConnection($"ws://127.0.0.1:{port}/");
+ await connection.InitializeStream();
+
+ var server = Substitute.For();
+ server.Running.Returns(true);
+
+ var request = new Request();
+ request.Apply(server);
+
+ var exception = await Assert.ThrowsExactlyAsync(() => connection.TryUpgrade(request));
+
+ Assert.AreEqual(ResponseStatus.BadGateway, exception.Status);
+
+ await acceptTask;
+ }
+
+}
diff --git a/Testing/Acceptance/Modules/ReverseProxy/WebsocketProxy/WebsocketTunnelTests.cs b/Testing/Acceptance/Modules/ReverseProxy/WebsocketProxy/WebsocketTunnelTests.cs
index 084d7690b..01ba13e5c 100644
--- a/Testing/Acceptance/Modules/ReverseProxy/WebsocketProxy/WebsocketTunnelTests.cs
+++ b/Testing/Acceptance/Modules/ReverseProxy/WebsocketProxy/WebsocketTunnelTests.cs
@@ -1,14 +1,49 @@
+using System.Net;
using System.Net.WebSockets;
+using System.Security.Authentication;
using System.Text;
using GenHTTP.Modules.Layouting;
using GenHTTP.Modules.ReverseProxy;
+using GenHTTP.Testing.Acceptance.Utilities;
+
namespace GenHTTP.Testing.Acceptance.Modules.ReverseProxy.WebsocketProxy;
[TestClass]
public class WebsocketTunnelTests
{
+ [TestMethod]
+ public async Task TestSecureUpstream()
+ {
+ await using var upstreamServer = new TestHost(Layout.Create().Build(), false);
+
+ var securePort = TestHost.NextPort();
+ var cert = await Utilities.Security.GetCertificateAsync();
+
+ upstreamServer.Host.Handler(GenHTTP.Modules.Websockets.Websocket.Functional()
+ .OnConnected(_ => ValueTask.CompletedTask)
+ .OnMessage(async (connection, message) => await connection.WriteAsync(message.Data))
+ .OnClose((_, __) => ValueTask.CompletedTask))
+ .Bind(IPAddress.Any, (ushort)securePort, cert, SslProtocols.Tls12);
+
+ await upstreamServer.StartAsync();
+
+ var proxy = Proxy.Create().Upstream($"https://localhost:{securePort}");
+
+ await using var runner = await TestHost.RunAsync(proxy);
+
+ // The proxy connects to the upstream over TLS using default (non-bypassed) certificate
+ // validation, so a self-signed test certificate is correctly rejected as untrusted - this
+ // exercises the SslStream upgrade path in RawWebsocketConnection, not a successful tunnel.
+ using var client = new ClientWebSocket();
+
+ var exception = await Assert.ThrowsExactlyAsync(() =>
+ client.ConnectAsync(new Uri($"ws://localhost:{runner.Port}"), CancellationToken.None));
+
+ Assert.AreEqual(WebSocketError.NotAWebSocket, exception.WebSocketErrorCode);
+ }
+
[TestMethod]
public async Task TestBasics()
{
diff --git a/Testing/Acceptance/Modules/Websockets/Integration/Imperative/FrameEdgeCaseTests.cs b/Testing/Acceptance/Modules/Websockets/Integration/Imperative/FrameEdgeCaseTests.cs
new file mode 100644
index 000000000..28d17ecf1
--- /dev/null
+++ b/Testing/Acceptance/Modules/Websockets/Integration/Imperative/FrameEdgeCaseTests.cs
@@ -0,0 +1,121 @@
+using System.Text;
+
+using GenHTTP.Modules.Websockets;
+using GenHTTP.Modules.Websockets.Protocol;
+
+using GenHTTP.Testing.Acceptance.Modules.Websockets.RawClient;
+
+namespace GenHTTP.Testing.Acceptance.Modules.Websockets.Integration.Imperative;
+
+[TestClass]
+public sealed class FrameEdgeCaseTests
+{
+
+ [TestMethod]
+ [MultiEngineTest]
+ public async Task TestPingInterruptsSegmentedMessage(TestEngine engine)
+ {
+ var websocket = GenHTTP.Modules.Websockets.Websocket.Imperative().Handler(new EchoAllHandler());
+
+ await using var host = await TestHost.RunAsync(websocket, engine: engine);
+
+ await using var client = new RawWebSocketClient();
+ await client.ConnectAsync("127.0.0.1", host.Port);
+
+ // Text(fin=0) + Ping(fin=1) interleaved + Continuation(fin=1) completing the message
+ var first = RawWebSocketClient.BuildClientFrame("Hello "u8.ToArray(), opcode: 0x1, fin: false);
+ var ping = RawWebSocketClient.BuildClientFrame("ping-data"u8.ToArray(), opcode: 0x9, fin: true);
+ var last = RawWebSocketClient.BuildClientFrame("World"u8.ToArray(), opcode: 0x0, fin: true);
+
+ var combined = first.Concat(ping).Concat(last).ToArray();
+ await client.SendRawInChunksAsync(combined, chunkSize: combined.Length);
+
+ var (pingResponseOpcode, _, pingResponsePayload) = await client.ReceiveFrameAsync();
+
+ Assert.AreEqual((byte)0xA, pingResponseOpcode);
+ Assert.AreEqual("ping-data", Encoding.UTF8.GetString(pingResponsePayload));
+
+ Assert.AreEqual("Hello World", await client.ReceiveTextFrameAsync());
+ }
+
+ [TestMethod]
+ [MultiEngineTest]
+ public async Task TestCloseInterruptsSegmentedMessage(TestEngine engine)
+ {
+ var websocket = GenHTTP.Modules.Websockets.Websocket.Imperative().Handler(new EchoAllHandler());
+
+ await using var host = await TestHost.RunAsync(websocket, engine: engine);
+
+ await using var client = new RawWebSocketClient();
+ await client.ConnectAsync("127.0.0.1", host.Port);
+
+ // Text(fin=0) followed directly by a Close - the in-progress segmented message is abandoned
+ var first = RawWebSocketClient.BuildClientFrame("Hello "u8.ToArray(), opcode: 0x1, fin: false);
+ var close = RawWebSocketClient.BuildClientFrame([], opcode: 0x8, fin: true);
+
+ var combined = first.Concat(close).ToArray();
+ await client.SendRawInChunksAsync(combined, chunkSize: combined.Length);
+
+ var (opcode, _, _) = await client.ReceiveFrameAsync();
+
+ Assert.AreEqual((byte)0x8, opcode);
+ }
+
+ [TestMethod]
+ public async Task TestTruncatedFrameAtEofYieldsError()
+ {
+ // Internal only: Kestrel tears down the connection outright on a half-closed upgrade
+ // stream instead of letting the handler write a response - a deeper ASP.NET Core
+ // transport behavior, not something specific to this (engine-agnostic) code path.
+ var websocket = GenHTTP.Modules.Websockets.Websocket.Imperative().Handler(new EchoAllHandler());
+
+ await using var host = await TestHost.RunAsync(websocket, engine: TestEngine.Internal);
+
+ await using var client = new RawWebSocketClient();
+ await client.ConnectAsync("127.0.0.1", host.Port);
+
+ var frame = RawWebSocketClient.BuildClientFrame("hello"u8.ToArray(), opcode: 0x1, fin: true);
+
+ // header + mask only (6 bytes for a small payload) - the payload never arrives
+ var partial = frame[..6];
+
+ await client.SendRawInChunksAsync(partial, chunkSize: partial.Length);
+
+ client.ShutdownWrite();
+
+ Assert.AreEqual(FrameError.UnexpectedEndOfStream, await client.ReceiveTextFrameAsync());
+ }
+
+ private sealed class EchoAllHandler : IImperativeHandler
+ {
+ public async ValueTask HandleAsync(IImperativeConnection connection)
+ {
+ while (true)
+ {
+ var frame = await connection.ReadFrameAsync();
+
+ if (frame.Type == FrameType.Close)
+ {
+ await connection.CloseAsync();
+ return;
+ }
+
+ if (frame.Type == FrameType.Ping)
+ {
+ await connection.PongAsync(frame.Data);
+ continue;
+ }
+
+ if (frame.IsError(out var error))
+ {
+ await connection.WriteAsync(Encoding.UTF8.GetBytes(error.Message));
+ await connection.CloseAsync();
+ return;
+ }
+
+ await connection.WriteAsync(frame.Data);
+ }
+ }
+ }
+
+}
diff --git a/Testing/Acceptance/Modules/Websockets/Integration/Pipelining/PipeliningTests.cs b/Testing/Acceptance/Modules/Websockets/Integration/Pipelining/PipeliningTests.cs
index e8fc61041..dd42436b0 100644
--- a/Testing/Acceptance/Modules/Websockets/Integration/Pipelining/PipeliningTests.cs
+++ b/Testing/Acceptance/Modules/Websockets/Integration/Pipelining/PipeliningTests.cs
@@ -1,3 +1,5 @@
+using System.Text;
+
using GenHTTP.Modules.Websockets;
using GenHTTP.Modules.Websockets.Protocol;
@@ -180,10 +182,56 @@ public async Task TestTryReadFrameReturnsFalseOnPartialFrame(TestEngine engine)
Assert.AreEqual("second", await client.ReceiveTextFrameAsync());
}
+ ///
+ /// A client-sent ping should be answered with a pong that either carries the ping's
+ /// payload or is empty, using both of the interface's PongAsync overloads.
+ ///
+ [TestMethod]
+ [MultiEngineTest]
+ public async Task TestPongOverloads(TestEngine engine)
+ {
+ var websocket = GenHTTP.Modules.Websockets.Websocket
+ .Imperative()
+ .Handler(new PongHandler());
+
+ await using var host = await TestHost.RunAsync(websocket, engine: engine);
+
+ await using var client = new RawWebSocketClient();
+ await client.ConnectAsync("127.0.0.1", host.Port);
+
+ await client.SendRawInChunksAsync(RawWebSocketClient.BuildClientFrame("ping-data"u8.ToArray(), opcode: 0x9, fin: true), chunkSize: 64);
+
+ var (opcode1, _, payload1) = await client.ReceiveFrameAsync();
+ var (opcode2, _, payload2) = await client.ReceiveFrameAsync();
+
+ Assert.AreEqual((byte)0xA, opcode1);
+ Assert.AreEqual("ping-data", Encoding.UTF8.GetString(payload1));
+
+ Assert.AreEqual((byte)0xA, opcode2);
+ Assert.AreEqual(0, payload2.Length);
+
+ Assert.AreEqual("done", await client.ReceiveTextFrameAsync());
+ }
+
// -------------------------------------------------------------------------
// Handlers
// -------------------------------------------------------------------------
+ private sealed class PongHandler : IImperativeHandler
+ {
+ public async ValueTask HandleAsync(IImperativeConnection connection)
+ {
+ var frame = await connection.ReadFrameAsync();
+ if (frame.Type == FrameType.Close) return;
+
+ await connection.PongAsync(frame.Data, flush: false);
+ await connection.PongAsync(flush: false);
+ await connection.WriteAsync("done"u8.ToArray());
+
+ await connection.CloseAsync();
+ }
+ }
+
private sealed class BatchHandler : IImperativeHandler
{
public async ValueTask HandleAsync(IImperativeConnection connection)
diff --git a/Testing/Acceptance/Modules/Websockets/RawClient/RawWebsocketClient.cs b/Testing/Acceptance/Modules/Websockets/RawClient/RawWebsocketClient.cs
index 0b606055a..5385a0c71 100644
--- a/Testing/Acceptance/Modules/Websockets/RawClient/RawWebsocketClient.cs
+++ b/Testing/Acceptance/Modules/Websockets/RawClient/RawWebsocketClient.cs
@@ -288,6 +288,62 @@ public async Task ReceiveTextFrameAsync(CancellationToken token = defaul
return Encoding.UTF8.GetString(payload);
}
+ ///
+ /// Receive a single raw WebSocket frame (any opcode) and return its opcode, FIN bit and payload.
+ ///
+ public async Task<(byte Opcode, bool Fin, byte[] Payload)> ReceiveFrameAsync(CancellationToken token = default)
+ {
+ var header = new byte[2];
+ await ReceiveExactAsync(header, token).ConfigureAwait(false);
+
+ var b0 = header[0];
+ var b1 = header[1];
+
+ var fin = (b0 & 0x80) != 0;
+ var opcode = (byte)(b0 & 0x0F);
+
+ var masked = (b1 & 0x80) != 0;
+ var len7 = (byte)(b1 & 0x7F);
+
+ long payloadLen = len7;
+
+ if (len7 == 126)
+ {
+ var lenBytes = new byte[2];
+ await ReceiveExactAsync(lenBytes, token).ConfigureAwait(false);
+ payloadLen = BinaryPrimitives.ReadUInt16BigEndian(lenBytes);
+ }
+ else if (len7 == 127)
+ {
+ var lenBytes = new byte[8];
+ await ReceiveExactAsync(lenBytes, token).ConfigureAwait(false);
+ payloadLen = (long)BinaryPrimitives.ReadUInt64BigEndian(lenBytes);
+ }
+
+ byte[]? maskKey = null;
+ if (masked)
+ {
+ maskKey = new byte[4];
+ await ReceiveExactAsync(maskKey, token).ConfigureAwait(false);
+ }
+
+ var payload = new byte[payloadLen];
+ if (payloadLen > 0)
+ {
+ await ReceiveExactAsync(payload.AsMemory(), token).ConfigureAwait(false);
+ }
+
+ if (masked && maskKey is not null)
+ {
+ for (var i = 0; i < payload.Length; i++)
+ {
+ payload[i] ^= maskKey[i & 0x03];
+ }
+ }
+
+ return (opcode, fin, payload);
+ }
+
public Task SendTextAsContinuationFramesInTcpChunksAsync(
string text,
int wsFragmentPayloadSize,
@@ -335,6 +391,11 @@ public Task SendTextAsContinuationFramesInTcpChunksAsync(
return SendRawInChunksAsync(buffer, tcpChunkSize, token);
}
+ ///
+ /// Half-closes the write side of the connection (sends FIN) while leaving the read side open.
+ ///
+ public void ShutdownWrite() => _socket.Shutdown(SocketShutdown.Send);
+
public ValueTask DisposeAsync()
{
try