mirror of
https://github.com/esiur/esiur-dotnet.git
synced 2026-07-30 17:30:40 +00:00
Permissions, RateControl and Auditing
This commit is contained in:
@@ -0,0 +1,541 @@
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using Esiur.Core;
|
||||
using Esiur.Data;
|
||||
using Esiur.Net;
|
||||
using Esiur.Net.Http;
|
||||
using Esiur.Net.Packets.Http;
|
||||
using Esiur.Net.Packets.WebSocket;
|
||||
using Esiur.Net.Sockets;
|
||||
using Esiur.Resource;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
|
||||
public class HttpWebSocketConnectionTests
|
||||
{
|
||||
[Fact]
|
||||
public void Handshake_RequiresGetHttp11TokensVersionAndCanonical16ByteKey()
|
||||
{
|
||||
Assert.True(HttpConnection.IsWebsocketRequest(ValidHandshake()));
|
||||
|
||||
var request = ValidHandshake();
|
||||
request.Method = Esiur.Net.Packets.Http.HttpMethod.POST;
|
||||
Assert.False(HttpConnection.IsWebsocketRequest(request));
|
||||
|
||||
request = ValidHandshake();
|
||||
request.Version = "HTTP/1.0";
|
||||
Assert.False(HttpConnection.IsWebsocketRequest(request));
|
||||
|
||||
request = ValidHandshake();
|
||||
request.RawMethod = "get";
|
||||
Assert.False(HttpConnection.IsWebsocketRequest(request));
|
||||
|
||||
request = ValidHandshake();
|
||||
request.Headers["Connection"] = "keep-alive, not-an-upgrade";
|
||||
Assert.False(HttpConnection.IsWebsocketRequest(request));
|
||||
|
||||
request = ValidHandshake();
|
||||
request.Headers["Upgrade"] = "notwebsocket";
|
||||
Assert.False(HttpConnection.IsWebsocketRequest(request));
|
||||
|
||||
request = ValidHandshake();
|
||||
request.Headers["Sec-WebSocket-Version"] = "12";
|
||||
Assert.False(HttpConnection.IsWebsocketRequest(request));
|
||||
|
||||
request = ValidHandshake();
|
||||
request.Headers["Sec-WebSocket-Key"] = "not-base64";
|
||||
Assert.False(HttpConnection.IsWebsocketRequest(request));
|
||||
|
||||
request = ValidHandshake();
|
||||
request.Headers["Sec-WebSocket-Key"] = Convert.ToBase64String(new byte[15]);
|
||||
Assert.False(HttpConnection.IsWebsocketRequest(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Upgrade_SelectsOnlyAnExplicitlySupportedSubprotocol()
|
||||
{
|
||||
var request = ValidHandshake();
|
||||
request.Headers["Sec-WebSocket-Protocol"] = "chat, superchat";
|
||||
var response = new HttpResponsePacket();
|
||||
response.Headers["Sec-WebSocket-Protocol"] = "untrusted";
|
||||
|
||||
Assert.True(HttpConnection.Upgrade(request, response));
|
||||
Assert.Null(response.Headers["Sec-WebSocket-Protocol"]);
|
||||
Assert.Equal("websocket", response.Headers["Upgrade"]);
|
||||
Assert.Equal("Upgrade", response.Headers["Connection"]);
|
||||
|
||||
response = new HttpResponsePacket();
|
||||
Assert.True(HttpConnection.Upgrade(
|
||||
request,
|
||||
response,
|
||||
new[] { "superchat", "chat" },
|
||||
out var selected));
|
||||
Assert.Equal("superchat", selected);
|
||||
Assert.Equal("superchat", response.Headers["Sec-WebSocket-Protocol"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MalformedAdvertisedUpgrade_IsRejectedBeforeFiltersOrRoutesRun()
|
||||
{
|
||||
var server = new HttpServer();
|
||||
var (connection, socket, filter) = CreateHttpConnection(server);
|
||||
var request = Encoding.ASCII.GetBytes(
|
||||
"GET / HTTP/1.1\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Sec-WebSocket-Version: 13\r\n" +
|
||||
"Sec-WebSocket-Key: invalid\r\n\r\n");
|
||||
|
||||
Receive(connection, socket, request);
|
||||
|
||||
Assert.Equal(SocketState.Closed, socket.State);
|
||||
Assert.False(connection.WSMode);
|
||||
Assert.Equal(0, filter.ExecutionCount);
|
||||
Assert.StartsWith("HTTP/1.1 400 Bad Request", Encoding.ASCII.GetString(Assert.Single(socket.Sent)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidUpgrade_UsesServerSubprotocolConfiguration()
|
||||
{
|
||||
var server = new HttpServer
|
||||
{
|
||||
WebSocketSubprotocols = new[] { "superchat" }
|
||||
};
|
||||
var (connection, socket, filter) = CreateHttpConnection(server);
|
||||
var request = Encoding.ASCII.GetBytes(
|
||||
"GET / HTTP/1.1\r\n" +
|
||||
"Connection: keep-alive, Upgrade\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Sec-WebSocket-Version: 13\r\n" +
|
||||
$"Sec-WebSocket-Key: {Convert.ToBase64String(new byte[16])}\r\n" +
|
||||
"Sec-WebSocket-Protocol: chat, superchat\r\n\r\n");
|
||||
|
||||
Receive(connection, socket, request);
|
||||
|
||||
Assert.Equal(SocketState.Established, socket.State);
|
||||
Assert.True(connection.WSMode);
|
||||
Assert.Equal("superchat", connection.WebSocketSubprotocol);
|
||||
Assert.Equal(1, filter.ExecutionCount);
|
||||
Assert.Contains(
|
||||
"sec-websocket-protocol: superchat\r\n",
|
||||
Encoding.ASCII.GetString(Assert.Single(socket.Sent)),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidUpgrade_DrainsFramesCoalescedWithTheHttpHandshake()
|
||||
{
|
||||
var (connection, socket, filter) = CreateHttpConnection();
|
||||
var handshake = Encoding.ASCII.GetBytes(
|
||||
"GET / HTTP/1.1\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Sec-WebSocket-Version: 13\r\n" +
|
||||
$"Sec-WebSocket-Key: {Convert.ToBase64String(new byte[16])}\r\n\r\n");
|
||||
var input = Concat(
|
||||
handshake,
|
||||
Frame(WebsocketPacket.WSOpcode.BinaryFrame, true, true, new byte[] { 1 }),
|
||||
Frame(WebsocketPacket.WSOpcode.BinaryFrame, true, true, new byte[] { 2 }));
|
||||
|
||||
Receive(connection, socket, input);
|
||||
|
||||
Assert.True(connection.WSMode);
|
||||
Assert.Equal(3, filter.ExecutionCount);
|
||||
Assert.Collection(
|
||||
filter.Messages,
|
||||
message => Assert.Equal(new byte[] { 1 }, message.Payload),
|
||||
message => Assert.Equal(new byte[] { 2 }, message.Payload));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltInWebSocket_SendAlwaysRecomposesServerFramesAsUnmasked()
|
||||
{
|
||||
var (connection, socket, _) = CreateWebSocketConnection();
|
||||
var packet = new WebsocketPacket
|
||||
{
|
||||
FIN = true,
|
||||
Opcode = WebsocketPacket.WSOpcode.BinaryFrame,
|
||||
Mask = true,
|
||||
MaskKey = new byte[] { 1, 2, 3, 4 },
|
||||
Message = new byte[] { 7, 8, 9 }
|
||||
};
|
||||
packet.Compose();
|
||||
|
||||
connection.Send(packet);
|
||||
|
||||
var sent = ParseServerFrame(Assert.Single(socket.Sent));
|
||||
Assert.False(sent.Mask);
|
||||
Assert.Equal(new byte[] { 7, 8, 9 }, sent.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltInWebSocket_ReassemblesMessagesHandlesPingAndDrainsCoalescedFrames()
|
||||
{
|
||||
var (connection, socket, filter) = CreateWebSocketConnection();
|
||||
var frames = Concat(
|
||||
Frame(WebsocketPacket.WSOpcode.TextFrame, false, true, Encoding.UTF8.GetBytes("hel")),
|
||||
Frame(WebsocketPacket.WSOpcode.Ping, true, true, Encoding.ASCII.GetBytes("p")),
|
||||
Frame(WebsocketPacket.WSOpcode.ContinuationFrame, true, true, Encoding.UTF8.GetBytes("lo")),
|
||||
Frame(WebsocketPacket.WSOpcode.BinaryFrame, true, true, new byte[] { 1, 2 }));
|
||||
|
||||
Receive(connection, socket, frames);
|
||||
|
||||
Assert.Equal(SocketState.Established, socket.State);
|
||||
Assert.Collection(
|
||||
filter.Messages,
|
||||
message =>
|
||||
{
|
||||
Assert.Equal(WebsocketPacket.WSOpcode.TextFrame, message.Opcode);
|
||||
Assert.Equal("hello", Encoding.UTF8.GetString(message.Payload));
|
||||
},
|
||||
message =>
|
||||
{
|
||||
Assert.Equal(WebsocketPacket.WSOpcode.BinaryFrame, message.Opcode);
|
||||
Assert.Equal(new byte[] { 1, 2 }, message.Payload);
|
||||
});
|
||||
|
||||
var pong = Assert.Single(socket.Sent);
|
||||
var parsedPong = ParseServerFrame(pong);
|
||||
Assert.Equal(WebsocketPacket.WSOpcode.Pong, parsedPong.Opcode);
|
||||
Assert.Equal("p", Encoding.ASCII.GetString(parsedPong.Message));
|
||||
Assert.Equal(WebsocketPacket.WSOpcode.BinaryFrame, connection.WSRequest.Opcode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltInWebSocket_RetainsAnIncompleteFrameWithoutLosingTheNextFrame()
|
||||
{
|
||||
var (connection, socket, filter) = CreateWebSocketConnection();
|
||||
var first = Frame(
|
||||
WebsocketPacket.WSOpcode.BinaryFrame,
|
||||
true,
|
||||
true,
|
||||
new byte[] { 1, 2, 3, 4 });
|
||||
var second = Frame(
|
||||
WebsocketPacket.WSOpcode.BinaryFrame,
|
||||
true,
|
||||
true,
|
||||
new byte[] { 5, 6 });
|
||||
var buffer = new NetworkBuffer();
|
||||
var split = first.Length - 2;
|
||||
|
||||
buffer.Write(first, 0, (uint)split);
|
||||
connection.NetworkReceive(socket, buffer);
|
||||
Assert.True(buffer.Protected);
|
||||
Assert.Empty(filter.Messages);
|
||||
|
||||
buffer.Write(Concat(first.Skip(split).ToArray(), second));
|
||||
connection.NetworkReceive(socket, buffer);
|
||||
|
||||
Assert.False(buffer.Protected);
|
||||
Assert.Collection(
|
||||
filter.Messages,
|
||||
message => Assert.Equal(new byte[] { 1, 2, 3, 4 }, message.Payload),
|
||||
message => Assert.Equal(new byte[] { 5, 6 }, message.Payload));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltInWebSocket_RejectsUnmaskedClientFramesWithProtocolClose()
|
||||
{
|
||||
var (connection, socket, filter) = CreateWebSocketConnection();
|
||||
|
||||
Receive(connection, socket, Frame(
|
||||
WebsocketPacket.WSOpcode.BinaryFrame,
|
||||
true,
|
||||
false,
|
||||
new byte[] { 1 }));
|
||||
|
||||
Assert.Equal(SocketState.Closed, socket.State);
|
||||
Assert.Empty(filter.Messages);
|
||||
AssertCloseCode(socket, 1002);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltInWebSocket_RejectsInvalidUtf8AcrossFragments()
|
||||
{
|
||||
var (connection, socket, filter) = CreateWebSocketConnection();
|
||||
|
||||
Receive(connection, socket, Concat(
|
||||
Frame(WebsocketPacket.WSOpcode.TextFrame, false, true, new byte[] { 0xC3 }),
|
||||
Frame(WebsocketPacket.WSOpcode.ContinuationFrame, true, true, new byte[] { 0x28 })));
|
||||
|
||||
Assert.Equal(SocketState.Closed, socket.State);
|
||||
Assert.Empty(filter.Messages);
|
||||
AssertCloseCode(socket, 1007);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltInWebSocket_EnforcesAggregateFragmentLimit()
|
||||
{
|
||||
var server = new HttpServer { MaximumWebSocketMessageLength = 4 };
|
||||
var (connection, socket, filter) = CreateWebSocketConnection(server);
|
||||
|
||||
Receive(connection, socket, Concat(
|
||||
Frame(WebsocketPacket.WSOpcode.BinaryFrame, false, true, new byte[] { 1, 2, 3 }),
|
||||
Frame(WebsocketPacket.WSOpcode.ContinuationFrame, true, true, new byte[] { 4, 5 })));
|
||||
|
||||
Assert.Equal(SocketState.Closed, socket.State);
|
||||
Assert.Empty(filter.Messages);
|
||||
AssertCloseCode(socket, 1009);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltInWebSocket_EchoesAValidClosePayloadThenCloses()
|
||||
{
|
||||
var (connection, socket, _) = CreateWebSocketConnection();
|
||||
var payload = new byte[] { 0x03, 0xE8 };
|
||||
|
||||
Receive(connection, socket, Frame(
|
||||
WebsocketPacket.WSOpcode.ConnectionClose,
|
||||
true,
|
||||
true,
|
||||
payload));
|
||||
|
||||
Assert.Equal(SocketState.Closed, socket.State);
|
||||
var close = ParseServerFrame(Assert.Single(socket.Sent));
|
||||
Assert.Equal(WebsocketPacket.WSOpcode.ConnectionClose, close.Opcode);
|
||||
Assert.Equal(payload, close.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltInWebSocket_WaitsForCloseFrameSendBeforeClosingTransport()
|
||||
{
|
||||
var pendingSend = new AsyncReply<bool>();
|
||||
var server = new HttpServer();
|
||||
var filter = new CaptureFilter();
|
||||
SetFilters(server, filter);
|
||||
var socket = new TestSocket(pendingSend);
|
||||
var connection = new HttpConnection { Server = server, WSMode = true };
|
||||
connection.Assign(socket);
|
||||
|
||||
Receive(connection, socket, Frame(
|
||||
WebsocketPacket.WSOpcode.ConnectionClose,
|
||||
true,
|
||||
true,
|
||||
new byte[] { 0x03, 0xE8 }));
|
||||
|
||||
Assert.Equal(SocketState.Established, socket.State);
|
||||
Assert.Single(socket.Sent);
|
||||
|
||||
pendingSend.Trigger(true);
|
||||
Assert.Equal(SocketState.Closed, socket.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExceptionDetails_AreGenericByDefaultAndEncodedWhenOptedIn()
|
||||
{
|
||||
var server = new HttpServer();
|
||||
var connection = new HttpConnection { Server = server };
|
||||
var exception = new InvalidOperationException("<script>secret</script>");
|
||||
|
||||
var genericPage = connection.FormatError500Page(exception);
|
||||
Assert.Contains("An internal server error occurred.", genericPage);
|
||||
Assert.DoesNotContain("secret", genericPage, StringComparison.Ordinal);
|
||||
|
||||
server.ExposeExceptionDetails = true;
|
||||
var detailedPage = connection.FormatError500Page(exception);
|
||||
Assert.Contains("<script>secret</script>", detailedPage);
|
||||
Assert.DoesNotContain("<script>", detailedPage, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionLookup_RestoresRefreshesAndStopsReturningDestroyedSessions()
|
||||
{
|
||||
var server = new HttpServer();
|
||||
var session = server.CreateSession("known-session", 0);
|
||||
var initialAction = session.LastAction;
|
||||
var request = new HttpRequestPacket { Cookies = new StringKeyList() };
|
||||
request.Cookies["SID"] = session.Id;
|
||||
var connection = new HttpConnection
|
||||
{
|
||||
Server = server,
|
||||
Request = request
|
||||
};
|
||||
|
||||
Assert.True(server.TryGetSession(session.Id, out var found));
|
||||
Assert.Same(session, found);
|
||||
|
||||
connection.RestoreSessionFromRequest();
|
||||
Assert.Same(session, connection.Session);
|
||||
Assert.True(session.LastAction >= initialAction);
|
||||
|
||||
session.Destroy();
|
||||
Assert.False(server.TryGetSession(session.Id, out _));
|
||||
connection.RestoreSessionFromRequest();
|
||||
Assert.Null(connection.Session);
|
||||
}
|
||||
|
||||
private static HttpRequestPacket ValidHandshake()
|
||||
{
|
||||
var headers = new StringKeyList();
|
||||
headers["Connection"] = "keep-alive, Upgrade";
|
||||
headers["Upgrade"] = "websocket";
|
||||
headers["Sec-WebSocket-Version"] = "13";
|
||||
headers["Sec-WebSocket-Key"] = Convert.ToBase64String(new byte[16]);
|
||||
return new HttpRequestPacket
|
||||
{
|
||||
Method = Esiur.Net.Packets.Http.HttpMethod.GET,
|
||||
Version = "HTTP/1.1",
|
||||
Headers = headers,
|
||||
URL = "/"
|
||||
};
|
||||
}
|
||||
|
||||
private static (HttpConnection Connection, TestSocket Socket, CaptureFilter Filter)
|
||||
CreateWebSocketConnection(HttpServer? server = null)
|
||||
{
|
||||
var result = CreateHttpConnection(server);
|
||||
result.Connection.WSMode = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
private static (HttpConnection Connection, TestSocket Socket, CaptureFilter Filter)
|
||||
CreateHttpConnection(HttpServer? server = null)
|
||||
{
|
||||
server ??= new HttpServer();
|
||||
var filter = new CaptureFilter();
|
||||
SetFilters(server, filter);
|
||||
|
||||
var socket = new TestSocket();
|
||||
var connection = new HttpConnection
|
||||
{
|
||||
Server = server
|
||||
};
|
||||
connection.Assign(socket);
|
||||
return (connection, socket, filter);
|
||||
}
|
||||
|
||||
private static void SetFilters(HttpServer server, params HttpFilter[] filters)
|
||||
=> typeof(HttpServer)
|
||||
.GetField("filters", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.SetValue(server, filters);
|
||||
|
||||
private static void Receive(
|
||||
HttpConnection connection,
|
||||
TestSocket socket,
|
||||
byte[] message)
|
||||
{
|
||||
var buffer = new NetworkBuffer();
|
||||
buffer.Write(message);
|
||||
connection.NetworkReceive(socket, buffer);
|
||||
}
|
||||
|
||||
private static byte[] Frame(
|
||||
WebsocketPacket.WSOpcode opcode,
|
||||
bool final,
|
||||
bool masked,
|
||||
byte[] payload)
|
||||
{
|
||||
var packet = new WebsocketPacket
|
||||
{
|
||||
FIN = final,
|
||||
Opcode = opcode,
|
||||
Mask = masked,
|
||||
MaskKey = new byte[] { 1, 2, 3, 4 },
|
||||
Message = payload
|
||||
};
|
||||
packet.Compose();
|
||||
return packet.Data;
|
||||
}
|
||||
|
||||
private static byte[] Concat(params byte[][] messages)
|
||||
{
|
||||
var length = messages.Sum(message => message.Length);
|
||||
var result = new byte[length];
|
||||
var offset = 0;
|
||||
foreach (var message in messages)
|
||||
{
|
||||
Buffer.BlockCopy(message, 0, result, offset, message.Length);
|
||||
offset += message.Length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static WebsocketPacket ParseServerFrame(byte[] data)
|
||||
{
|
||||
var packet = new WebsocketPacket { ExpectedMask = false };
|
||||
Assert.Equal(data.Length, packet.Parse(data, 0, (uint)data.Length));
|
||||
return packet;
|
||||
}
|
||||
|
||||
private static void AssertCloseCode(TestSocket socket, int expected)
|
||||
{
|
||||
var close = ParseServerFrame(Assert.Single(socket.Sent));
|
||||
Assert.Equal(WebsocketPacket.WSOpcode.ConnectionClose, close.Opcode);
|
||||
Assert.Equal(expected, close.Message[0] << 8 | close.Message[1]);
|
||||
}
|
||||
|
||||
private sealed class CaptureFilter : HttpFilter
|
||||
{
|
||||
public List<(WebsocketPacket.WSOpcode Opcode, byte[] Payload)> Messages { get; } = new();
|
||||
public int ExecutionCount { get; private set; }
|
||||
|
||||
public override AsyncReply<bool> Execute(HttpConnection sender)
|
||||
{
|
||||
ExecutionCount++;
|
||||
var packet = sender.WSRequest;
|
||||
if (packet != null)
|
||||
Messages.Add((packet.Opcode, packet.Message.ToArray()));
|
||||
return new AsyncReply<bool>(true);
|
||||
}
|
||||
|
||||
public override AsyncReply<bool> Handle(
|
||||
ResourceOperation operation,
|
||||
IResourceContext? context = null) => new(true);
|
||||
}
|
||||
|
||||
private sealed class TestSocket : ISocket
|
||||
{
|
||||
private readonly AsyncReply<bool>? sendReply;
|
||||
|
||||
public TestSocket(AsyncReply<bool>? sendReply = null)
|
||||
=> this.sendReply = sendReply;
|
||||
|
||||
public event DestroyedEvent? OnDestroy;
|
||||
public SocketState State { get; private set; } = SocketState.Established;
|
||||
public INetworkReceiver<ISocket> Receiver { get; set; } = null!;
|
||||
public IPEndPoint RemoteEndPoint { get; } = new(IPAddress.Loopback, 12345);
|
||||
public IPEndPoint LocalEndPoint { get; } = new(IPAddress.Loopback, 54321);
|
||||
public List<byte[]> Sent { get; } = new();
|
||||
|
||||
public void Send(byte[] message) => Send(message, 0, message.Length);
|
||||
|
||||
public void Send(byte[] message, int offset, int length)
|
||||
{
|
||||
var copy = new byte[length];
|
||||
Buffer.BlockCopy(message, offset, copy, 0, length);
|
||||
Sent.Add(copy);
|
||||
}
|
||||
|
||||
public AsyncReply<bool> SendAsync(byte[] message, int offset, int length)
|
||||
{
|
||||
Send(message, offset, length);
|
||||
return sendReply ?? new AsyncReply<bool>(true);
|
||||
}
|
||||
|
||||
public void Close()
|
||||
{
|
||||
if (State == SocketState.Closed)
|
||||
return;
|
||||
|
||||
State = SocketState.Closed;
|
||||
Receiver?.NetworkClose(this);
|
||||
}
|
||||
|
||||
public void Destroy()
|
||||
{
|
||||
Close();
|
||||
OnDestroy?.Invoke(this);
|
||||
OnDestroy = null;
|
||||
}
|
||||
|
||||
public AsyncReply<bool> Connect(string hostname, ushort port) => new(false);
|
||||
public bool Begin() => true;
|
||||
public AsyncReply<bool> BeginAsync() => new(true);
|
||||
public AsyncReply<ISocket> AcceptAsync() => new((ISocket)null!);
|
||||
public ISocket Accept() => null!;
|
||||
public void Hold() { }
|
||||
public void Unhold() { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user