Skip to content

Commit 3c0d6cb

Browse files
authored
feat: Upgrades networking to use io_uring. (#2315)
> [!IMPORTANT] > **Breaking Changes** > - DecodePacket and EncodePacket delegates replaced with IClientEncryption interface > - NetState.Connection (Socket) replaced with internal RingSocket management > - NetState.RecvPipe and NetState.SendPipe removed (buffers managed internally) ## Summary Upgrades the networking stack from PollGroup-based I/O to io_uring, significantly improving I/O performance on Linux. This also adds native client encryption support for encrypted UO clients. ## Major Changes io_uring Networking Architecture - Replaced PollGroup with IORingGroup for async socket I/O operations - Removed Pipe.cs (mirrored ring buffer) and TcpServer.cs in favor of RingSocketManager - Added NetState.Network.cs - centralized network infrastructure handling accept, recv, send, and disconnect completions - Added SocketHelper.cs - platform-specific socket utilities for raw socket handle operations (getpeername, getsockname) - Buffer management now handled by RingSocketManager with configurable slab allocation ### Client Encryption Support - Added full encryption stack in Network/Encryption/: - EncryptionConfig.cs - configurable encryption modes (None, Unencrypted, Encrypted, Both) - EncryptionManager.cs - encryption detection and initialization for login/game packets - LoginEncryption.cs - handles login packet encryption with version-derived keys - GameEncryption.cs - handles game server encryption using Twofish - TwofishEngine.cs - optimized Twofish block cipher implementation - LoginKeys.cs - encryption key table for client versions - IClientEncryption.cs - interface for client encryption implementations ### NetState Improvements - Replaced Socket Connection with RingSocket _socket for managed socket lifecycle - Changed from GCHandle polling to event-based completion processing - Disconnect handling now properly waits for pending sends to flush - Simplified connecting socket management using lazy queue removal ### Configuration - New settings: network.encryptionMode and network.encryptionDebug - Encryption mode flags: Unencrypted, Encrypted, or Both ### Dependencies - Replaced PollGroup NuGet package with IORingGroup - Linux requires liburing-dev / liburing-devel package ### Test plan - Verify server starts and accepts connections on Linux with io_uring - Verify server starts and accepts connections on Windows (fallback to IOCP) - Test unencrypted client connections (ClassicUO with encryption disabled) - Test encrypted client connections if available - Verify graceful disconnect flushes pending data - Confirm CI builds pass on all target platforms
1 parent 91a553b commit 3c0d6cb

File tree

70 files changed

+2224
-1465
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

70 files changed

+2224
-1465
lines changed

.github/workflows/build-test.yml

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ jobs:
4242

4343
build-linux:
4444
runs-on: ubuntu-latest
45-
container: ${{ matrix.container }}
45+
container:
46+
image: ${{ matrix.container }}
47+
options: --security-opt seccomp=unconfined
4648
name: Build (${{ matrix.name }})
4749
strategy:
4850
fail-fast: false
@@ -68,14 +70,17 @@ jobs:
6870
packageManager: dnf
6971

7072
steps:
71-
- name: Enable EPEL
72-
run: dnf upgrade --refresh -y && dnf install -y epel-release epel-next-release
73-
if: ${{ startsWith(matrix.name, 'CentOS') }}
73+
- name: Enable EPEL and CRB for CentOS
74+
run: |
75+
dnf upgrade --refresh -y
76+
dnf install -y epel-release epel-next-release
77+
dnf config-manager --set-enabled crb
78+
if: ${{ startsWith(matrix.name, 'CentOS') }}
7479
- name: Install Prerequisites using dnf
75-
run: dnf makecache --refresh && dnf install -y findutils libicu libdeflate-devel zstd libargon2-devel
80+
run: dnf makecache --refresh && dnf install -y findutils libicu libdeflate-devel zstd libargon2-devel liburing-devel
7681
if: ${{ matrix.packageManager == 'dnf' }}
7782
- name: Install Prerequisites using apt
78-
run: apt-get update -y && apt-get install -y curl libicu-dev libdeflate-dev zstd libargon2-dev tzdata
83+
run: apt-get update -y && apt-get install -y curl libicu-dev libdeflate-dev zstd libargon2-dev tzdata liburing-dev
7984
if: ${{ matrix.packageManager == 'apt' }}
8085
- uses: actions/checkout@v4
8186
with:

Projects/Server.Tests/Fixtures/TestServerInitializer.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ public static void Initialize(bool loadTileData = false)
7878
Core.LoopContext = new EventLoopContext();
7979
Core.Expansion = Expansion.EJ;
8080

81+
// Configure networking (initializes RingSocketManager for tests)
82+
Server.Network.NetState.Configure();
83+
8184
// Configure / Initialize
8285
TestMapDefinitions.ConfigureTestMapDefinitions();
8386

Lines changed: 85 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,94 @@
11
using System;
2+
using System.Collections.Generic;
3+
using System.Net;
24
using System.Net.Sockets;
5+
using System.Network;
36
using Server.Network;
47

5-
namespace Server.Tests.Network
8+
namespace Server.Tests.Network;
9+
10+
public static class PacketTestUtilities
611
{
7-
public static class PacketTestUtilities
12+
private static nint _testListener;
13+
private static int _testPort;
14+
private static readonly List<Socket> _testSocketClients = [];
15+
16+
public static Span<byte> Compile(this Packet p) => p.Compile(false, out var length).AsSpan(0, length);
17+
18+
/// <summary>
19+
/// Creates a NetState for unit testing.
20+
/// Uses a real Socket and RingSocket with actual buffers.
21+
/// Must be disposed after use (use 'using' statement).
22+
/// </summary>
23+
public static NetState CreateTestNetState()
824
{
9-
public static Span<byte> Compile(this Packet p) =>
10-
p.Compile(false, out var length).AsSpan(0, length);
25+
NetState.Slice(); // Process disconnects/disposes
26+
27+
for (var i = _testSocketClients.Count - 1; i >= 0; i--)
28+
{
29+
var sock = _testSocketClients[i];
30+
if (!sock.Connected)
31+
{
32+
sock.Dispose();
33+
_testSocketClients.RemoveAt(i);
34+
}
35+
}
36+
37+
var ring = NetState.Ring;
38+
39+
// Create a test listener if we don't have one (using the ring for RIO-compatible sockets)
40+
if (_testListener == 0)
41+
{
42+
// Disable rate limiter for tests - we don't want connection attempts to be throttled
43+
// NetState.DisableRateLimiter();
44+
45+
_testListener = ring.CreateListener("127.0.0.1", 0, 128);
46+
if (_testListener == -1)
47+
{
48+
throw new InvalidOperationException("Failed to create test listener");
49+
}
50+
51+
_testPort = SocketHelper.GetLocalEndPoint(_testListener)?.Port ?? 0;
52+
if (_testPort == 0)
53+
{
54+
throw new InvalidOperationException("Failed to get test listener port");
55+
}
56+
}
57+
58+
// Queue an accept operation
59+
ring.PrepareAccept(_testListener, 0, 0, IORingUserData.EncodeAccept());
60+
ring.Submit();
61+
62+
Core._now = DateTime.UtcNow;
63+
64+
// Create a client socket and connect to trigger the accept
65+
var testSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
66+
testSocket.Connect(IPAddress.Loopback, _testPort);
67+
_testSocketClients.Add(testSocket);
68+
69+
// Slice until we have a new NetState instance added
70+
// AcceptEx is asynchronous, so we may need to wait/retry
71+
const int maxRetries = 100;
72+
for (var i = 0; i < maxRetries; i++)
73+
{
74+
NetState.Slice();
75+
76+
// Get the latest instance connected.
77+
foreach (var ns in NetState.Instances)
78+
{
79+
if (ns.ConnectedOn == Core._now)
80+
{
81+
return ns;
82+
}
83+
}
84+
85+
// Wait a bit for AcceptEx to complete
86+
if (i < maxRetries - 1)
87+
{
88+
System.Threading.Thread.Sleep(1);
89+
}
90+
}
1191

12-
public static NetState CreateTestNetState() => new(
13-
new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)
14-
);
92+
throw new Exception("Failed to slice for test NetState instance after retries");
1593
}
1694
}

Projects/Server.Tests/Server.Tests.csproj

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@
1616
<DataFiles Include="$(SolutionDir)\Distribution\Data\**" />
1717
<ProjectReference Include="..\UOContent\UOContent.csproj" />
1818
</ItemGroup>
19+
<!-- Copy native ioring.dll for tests -->
20+
<ItemGroup>
21+
<Content Include="C:\Repositories\IORingGroup\IORingGroup\runtimes\win-x64\native\ioring.dll" Condition="Exists('C:\Repositories\IORingGroup\IORingGroup\runtimes\win-x64\native\ioring.dll')">
22+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
23+
<Link>ioring.dll</Link>
24+
</Content>
25+
</ItemGroup>
1926
<Target Name="CopyData" AfterTargets="AfterBuild">
2027
<Copy SourceFiles="@(DataFiles)" DestinationFolder="$(OutDir)\Data\%(RecursiveDir)" />
2128
</Target>

Projects/Server.Tests/Tests/Maps/ClientEnumeratorTests.cs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
using System;
22
using System.Collections.Generic;
3-
using System.Net.Sockets;
43
using Server.Accounting;
54
using Server.Network;
5+
using Server.Tests.Network;
66
using Xunit;
77

88
namespace Server.Tests.Maps;
@@ -356,7 +356,7 @@ public void ClientEnumerator_GetClientsInRange()
356356
{
357357
var map = Map.Felucca;
358358
var center = new Point3D(900, 900, 0);
359-
var range = 5;
359+
const int range = 5;
360360

361361
var clients = new (NetState, Mobile)[3];
362362
try
@@ -456,8 +456,8 @@ public Mobile this[int index]
456456

457457
private static (NetState, Mobile) CreateClientWithMobile(Map map, Point3D location)
458458
{
459-
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
460-
var ns = new NetState(socket);
459+
// Create test NetState with real socket and buffers
460+
var ns = PacketTestUtilities.CreateTestNetState();
461461

462462
// Assign a mock account to avoid null reference issues
463463
ns.Account = new MockAccount();
@@ -533,16 +533,16 @@ public void ClientEnumerator_NegativeRangeCreates1x1Bounds()
533533
}
534534
}
535535

536-
private static void DeleteAll((NetState, Mobile)[] clients)
536+
private static void DeleteAll((NetState state, Mobile m)[] clients)
537537
{
538538
for (var i = 0; i < clients.Length; i++)
539539
{
540-
if (clients[i].Item1 != null)
540+
if (clients[i].state != null)
541541
{
542-
clients[i].Item1.Mobile = null;
543-
clients[i].Item1.Disconnect("Test cleanup");
542+
clients[i].state.Mobile = null;
543+
clients[i].state.Dispose();
544544
}
545-
clients[i].Item2?.Delete();
545+
clients[i].m?.Delete();
546546
}
547547
}
548548
}

Projects/Server.Tests/Tests/Maps/MapSelectionTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
using System.Text.Json.Serialization;
55
using System.Text.Json;
66

7-
namespace Server.Tests.Tests.Maps
7+
namespace Server.Tests.Maps
88
{
99
public class MapSelectionTests
1010
{

Projects/Server.Tests/Tests/Network/Packets/Outgoing/AccountPacketTests.cs

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ public void TestChangeCharacter()
9292
using var ns = PacketTestUtilities.CreateTestNetState();
9393
ns.SendChangeCharacter(account);
9494

95-
var result = ns.SendPipe.Reader.AvailableToRead();
95+
var result = ns.SendBuffer.GetReadSpan();
9696
AssertThat.Equal(result, expected);
9797
}
9898

@@ -105,7 +105,7 @@ public void TestClientVersionReq()
105105

106106
ns.SendClientVersionRequest();
107107

108-
var result = ns.SendPipe.Reader.AvailableToRead();
108+
var result = ns.SendBuffer.GetReadSpan();
109109
AssertThat.Equal(result, expected);
110110
}
111111

@@ -117,7 +117,7 @@ public void TestDeleteResult()
117117
using var ns = PacketTestUtilities.CreateTestNetState();
118118
ns.SendCharacterDeleteResult(DeleteResultType.BadRequest);
119119

120-
var result = ns.SendPipe.Reader.AvailableToRead();
120+
var result = ns.SendBuffer.GetReadSpan();
121121
AssertThat.Equal(result, expected);
122122
}
123123

@@ -129,7 +129,7 @@ public void TestPopupMessage()
129129
using var ns = PacketTestUtilities.CreateTestNetState();
130130
ns.SendPopupMessage(PMMessage.LoginSyncError);
131131

132-
var result = ns.SendPipe.Reader.AvailableToRead();
132+
var result = ns.SendBuffer.GetReadSpan();
133133
AssertThat.Equal(result, expected);
134134
}
135135

@@ -156,7 +156,7 @@ public void TestSupportedFeatures(ProtocolChanges protocolChanges)
156156
var expected = new SupportedFeatures(ns).Compile();
157157
ns.SendSupportedFeature();
158158

159-
var result = ns.SendPipe.Reader.AvailableToRead();
159+
var result = ns.SendBuffer.GetReadSpan();
160160
AssertThat.Equal(result, expected);
161161
}
162162

@@ -177,7 +177,7 @@ public void TestLoginConfirm()
177177
using var ns = PacketTestUtilities.CreateTestNetState();
178178
ns.SendLoginConfirmation(m);
179179

180-
var result = ns.SendPipe.Reader.AvailableToRead();
180+
var result = ns.SendBuffer.GetReadSpan();
181181
AssertThat.Equal(result, expected);
182182
}
183183

@@ -189,7 +189,7 @@ public void TestLoginComplete()
189189
using var ns = PacketTestUtilities.CreateTestNetState();
190190
ns.SendLoginComplete();
191191

192-
var result = ns.SendPipe.Reader.AvailableToRead();
192+
var result = ns.SendBuffer.GetReadSpan();
193193
AssertThat.Equal(result, expected);
194194
}
195195

@@ -214,7 +214,7 @@ public void TestCharacterListUpdate()
214214
using var ns = PacketTestUtilities.CreateTestNetState();
215215
ns.SendCharacterListUpdate(account);
216216

217-
var result = ns.SendPipe.Reader.AvailableToRead();
217+
var result = ns.SendBuffer.GetReadSpan();
218218
AssertThat.Equal(result, expected);
219219
}
220220

@@ -248,7 +248,7 @@ public void TestCharacterList70130()
248248

249249
ns.SendCharacterList();
250250

251-
var result = ns.SendPipe.Reader.AvailableToRead();
251+
var result = ns.SendBuffer.GetReadSpan();
252252
AssertThat.Equal(result, expected);
253253
}
254254

@@ -281,7 +281,7 @@ public void TestCharacterListOld()
281281

282282
ns.SendCharacterList();
283283

284-
var result = ns.SendPipe.Reader.AvailableToRead();
284+
var result = ns.SendBuffer.GetReadSpan();
285285
AssertThat.Equal(result, expected);
286286
}
287287

@@ -294,7 +294,7 @@ public void TestAccountLoginRej()
294294
using var ns = PacketTestUtilities.CreateTestNetState();
295295
ns.SendAccountLoginRejected(reason);
296296

297-
var result = ns.SendPipe.Reader.AvailableToRead();
297+
var result = ns.SendBuffer.GetReadSpan();
298298
AssertThat.Equal(result, expected);
299299
}
300300

@@ -313,23 +313,23 @@ public void TestAccountLoginAck()
313313

314314
ns.SendAccountLoginAck();
315315

316-
var result = ns.SendPipe.Reader.AvailableToRead();
316+
var result = ns.SendBuffer.GetReadSpan();
317317
AssertThat.Equal(result, expected);
318318
}
319319

320320
[Fact]
321321
public void TestPlayServerAck()
322322
{
323323
var si = new ServerInfo("Test Server", 0, TimeZoneInfo.Local, IPEndPoint.Parse("127.0.0.1"));
324-
var authId = 0x123456;
324+
const int authId = 0x123456;
325325

326326
var expected = new PlayServerAck(si, authId).Compile();
327327

328328
using var ns = PacketTestUtilities.CreateTestNetState();
329329

330330
ns.SendPlayServerAck(si, authId);
331331

332-
var result = ns.SendPipe.Reader.AvailableToRead();
332+
var result = ns.SendBuffer.GetReadSpan();
333333
AssertThat.Equal(result, expected);
334334
}
335335
}

Projects/Server.Tests/Tests/Network/Packets/Outgoing/CombatPacketTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public void TestSwing()
1717
using var ns = PacketTestUtilities.CreateTestNetState();
1818
ns.SendSwing(attacker, defender);
1919

20-
var result = ns.SendPipe.Reader.AvailableToRead();
20+
var result = ns.SendBuffer.GetReadSpan();
2121
AssertThat.Equal(result, expected);
2222
}
2323

@@ -29,7 +29,7 @@ public void TestSetWarMode(bool warmode)
2929
using var ns = PacketTestUtilities.CreateTestNetState();
3030
ns.SendSetWarMode(warmode);
3131

32-
var result = ns.SendPipe.Reader.AvailableToRead();
32+
var result = ns.SendBuffer.GetReadSpan();
3333
AssertThat.Equal(result, expected);
3434
}
3535

@@ -43,7 +43,7 @@ public void TestChangeCombatant()
4343
using var ns = PacketTestUtilities.CreateTestNetState();
4444
ns.SendChangeCombatant(serial);
4545

46-
var result = ns.SendPipe.Reader.AvailableToRead();
46+
var result = ns.SendBuffer.GetReadSpan();
4747
AssertThat.Equal(result, expected);
4848
}
4949
}

0 commit comments

Comments
 (0)