From 0f56e66f9f81d01d99eb75767c03d6c66a378bd2 Mon Sep 17 00:00:00 2001 From: redouane Date: Tue, 8 Sep 2026 00:36:09 +0200 Subject: [PATCH 01/21] feat(engine): make the endpoint path configurable Both transports hardcoded "/engine.io", which is where a bare Engine.IO server listens but not where a Socket.IO server carries the same protocol. ClientOptions.Path now names it and both transports take it. The normalized form keeps a trailing slash. That is not cosmetic: a server normalizes its own path the same way and matches it against the start of the request, so a Socket.IO server answers "/socket.io/?EIO=4" and 404s "/socket.io?EIO=4". The bundled Engine.IO sample accepted the slashless form only because it forwards every request past engine.io's own path check. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wpg9UqbFRzXvmpp2Msbieu --- src/EngineIO.Client/ClientOptions.cs | 11 +++++++ src/EngineIO.Client/Engine.cs | 9 +++--- .../Transports/HttpPollingTransport.cs | 8 ++--- .../Transports/TransportPath.cs | 31 +++++++++++++++++++ .../Transports/WebSocketTransport.cs | 9 +++--- .../Transports/HttpPollingTransportTests.cs | 15 +++++++-- .../Transports/WebSocketTransportTests.cs | 14 ++++++++- 7 files changed, 82 insertions(+), 15 deletions(-) create mode 100644 src/EngineIO.Client/Transports/TransportPath.cs diff --git a/src/EngineIO.Client/ClientOptions.cs b/src/EngineIO.Client/ClientOptions.cs index 789c965..f3b6347 100644 --- a/src/EngineIO.Client/ClientOptions.cs +++ b/src/EngineIO.Client/ClientOptions.cs @@ -1,3 +1,5 @@ +using EngineIO.Client.Transports; + namespace EngineIO.Client; public class ClientOptions @@ -7,6 +9,15 @@ public class ClientOptions /// public string BaseAddress { get; set; } = null!; + /// + /// Path the Engine.io endpoint is served from. + /// + /// + /// A Socket.IO server carries Engine.io under its own path — "/socket.io" by + /// default — so the client has to be told where to look. + /// + public string Path { get; set; } = TransportPath.Default; + /// /// Flag indicating whether client should automatically update from HTTP polling to websocket transport. /// diff --git a/src/EngineIO.Client/Engine.cs b/src/EngineIO.Client/Engine.cs index f7876d2..9559f14 100644 --- a/src/EngineIO.Client/Engine.cs +++ b/src/EngineIO.Client/Engine.cs @@ -143,8 +143,8 @@ private void DisposeCore() public async Task ConnectAsync(CancellationToken cancellationToken = default) { _transport = _httpTransport = _httpClient is null - ? new HttpPollingTransport(_clientOptions.BaseAddress) - : new HttpPollingTransport(_httpClient); + ? new HttpPollingTransport(_clientOptions.BaseAddress, _clientOptions.Path) + : new HttpPollingTransport(_httpClient, _clientOptions.Path); try { await _httpTransport.ConnectAsync(cancellationToken).ConfigureAwait(false); @@ -160,8 +160,9 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) try { _transport = _wsTransport = _webSocket is null - ? new WebSocketTransport(_clientOptions.BaseAddress, _httpTransport.Sid!) - : new WebSocketTransport(_webSocket, _clientOptions.BaseAddress, _httpTransport.Sid!); + ? new WebSocketTransport(_clientOptions.BaseAddress, _httpTransport.Sid!, _clientOptions.Path) + : new WebSocketTransport(_webSocket, _clientOptions.BaseAddress, _httpTransport.Sid!, + _clientOptions.Path); await _wsTransport.ConnectAsync(cancellationToken).ConfigureAwait(false); } catch (Exception exception) diff --git a/src/EngineIO.Client/Transports/HttpPollingTransport.cs b/src/EngineIO.Client/Transports/HttpPollingTransport.cs index ca07dcb..8f31521 100644 --- a/src/EngineIO.Client/Transports/HttpPollingTransport.cs +++ b/src/EngineIO.Client/Transports/HttpPollingTransport.cs @@ -42,17 +42,17 @@ public sealed class HttpPollingTransport : ITransport, IDisposable /// private Uri _requestUri = null!; - public HttpPollingTransport(string baseAddress) + public HttpPollingTransport(string baseAddress, string path = TransportPath.Default) { _httpClient = new HttpClient(); _httpClient.BaseAddress = new Uri(baseAddress); - SetPath($"/engine.io?EIO={_protocol}&transport={Name}"); + SetPath($"{TransportPath.Normalize(path)}?EIO={_protocol}&transport={Name}"); } - internal HttpPollingTransport(HttpClient httpClient) + internal HttpPollingTransport(HttpClient httpClient, string path = TransportPath.Default) { _httpClient = httpClient; - SetPath($"/engine.io?EIO={_protocol}&transport={Name}"); + SetPath($"{TransportPath.Normalize(path)}?EIO={_protocol}&transport={Name}"); } public string Path => _path; diff --git a/src/EngineIO.Client/Transports/TransportPath.cs b/src/EngineIO.Client/Transports/TransportPath.cs new file mode 100644 index 0000000..23131b7 --- /dev/null +++ b/src/EngineIO.Client/Transports/TransportPath.cs @@ -0,0 +1,31 @@ +using System; + +namespace EngineIO.Client.Transports; + +/// +/// The path an Engine.IO endpoint is served from. +/// +internal static class TransportPath +{ + /// + /// Where a bare Engine.IO server listens. A Socket.IO server speaks the same + /// protocol but serves it from "/socket.io" instead, which is why the path is + /// configurable at all. + /// + public const string Default = "/engine.io"; + + /// + /// Bring a caller-supplied path to the form a server actually matches on: a + /// leading and a trailing slash, with the query string appended straight after. + /// + /// + /// The trailing slash is not cosmetic. Both servers normalize their configured + /// path the same way and compare it against the start of the request, so a + /// Socket.IO server answers "/socket.io/?EIO=4" and 404s "/socket.io?EIO=4". + /// + public static string Normalize(string? path) + { + var trimmed = (path ?? Default).Trim().Trim('/'); + return trimmed.Length == 0 ? "/" : $"/{trimmed}/"; + } +} \ No newline at end of file diff --git a/src/EngineIO.Client/Transports/WebSocketTransport.cs b/src/EngineIO.Client/Transports/WebSocketTransport.cs index 5f16d03..61377ff 100644 --- a/src/EngineIO.Client/Transports/WebSocketTransport.cs +++ b/src/EngineIO.Client/Transports/WebSocketTransport.cs @@ -33,12 +33,13 @@ public sealed class WebSocketTransport : ITransport, IDisposable private bool _connected; - public WebSocketTransport(string baseAddress, string sid) - : this(new ClientWebSocketAdapter(), baseAddress, sid) + public WebSocketTransport(string baseAddress, string sid, string path = TransportPath.Default) + : this(new ClientWebSocketAdapter(), baseAddress, sid, path) { } - internal WebSocketTransport(IWebSocket client, string baseAddress, string sid) + internal WebSocketTransport(IWebSocket client, string baseAddress, string sid, + string path = TransportPath.Default) { if (string.IsNullOrEmpty(baseAddress)) { @@ -62,7 +63,7 @@ internal WebSocketTransport(IWebSocket client, string baseAddress, string sid) baseAddress = baseAddress.Replace("https://", "wss://"); } - var uri = $"{baseAddress}/engine.io?EIO={_protocol}&transport={Name}&sid={sid}"; + var uri = $"{baseAddress}{TransportPath.Normalize(path)}?EIO={_protocol}&transport={Name}&sid={sid}"; _uri = new Uri(uri); } diff --git a/tests/EngineIO.Client.Tests/Transports/HttpPollingTransportTests.cs b/tests/EngineIO.Client.Tests/Transports/HttpPollingTransportTests.cs index 7bdc76a..4764729 100644 --- a/tests/EngineIO.Client.Tests/Transports/HttpPollingTransportTests.cs +++ b/tests/EngineIO.Client.Tests/Transports/HttpPollingTransportTests.cs @@ -15,7 +15,18 @@ public class HttpPollingTransportTests void Should_Create_Transport() { var transport = new HttpPollingTransport("http://127.0.0.1:3000"); - Assert.Equal($"/engine.io?EIO=4&transport=polling", transport.Path); + Assert.Equal($"/engine.io/?EIO=4&transport=polling", transport.Path); + } + + [Theory(DisplayName = "The endpoint is served from the configured path")] + [InlineData("/socket.io", "/socket.io/")] + [InlineData("socket.io", "/socket.io/")] + [InlineData("/socket.io/", "/socket.io/")] + [InlineData("", "/")] + void Should_Serve_From_The_Configured_Path(string path, string expectedPath) + { + var transport = new HttpPollingTransport("http://127.0.0.1:3000", path); + Assert.Equal($"{expectedPath}?EIO=4&transport=polling", transport.Path); } [Fact] @@ -101,7 +112,7 @@ async Task Should_Connect() Assert.Equal(pingInterval, transport.PingInterval); Assert.Equal(pingTimeout, transport.PingTimeout); Assert.Equal(upgrades, transport.Upgrades); - Assert.Equal($"/engine.io?EIO=4&transport=polling&sid={sid}", transport.Path); + Assert.Equal($"/engine.io/?EIO=4&transport=polling&sid={sid}", transport.Path); } [Theory(DisplayName = "Separators that enclose no payload are skipped")] diff --git a/tests/EngineIO.Client.Tests/Transports/WebSocketTransportTests.cs b/tests/EngineIO.Client.Tests/Transports/WebSocketTransportTests.cs index e67ba10..cabaf1c 100644 --- a/tests/EngineIO.Client.Tests/Transports/WebSocketTransportTests.cs +++ b/tests/EngineIO.Client.Tests/Transports/WebSocketTransportTests.cs @@ -19,10 +19,22 @@ void Should_Map_Http_Scheme_To_WebSocket_Scheme(string baseAddress, string expec using var transport = new WebSocketTransport(baseAddress, sid); Assert.Equal(expectedScheme, transport.Uri.Scheme); - Assert.Equal("/engine.io", transport.Uri.AbsolutePath); + Assert.Equal("/engine.io/", transport.Uri.AbsolutePath); Assert.Equal($"?EIO=4&transport=websocket&sid={sid}", transport.Uri.Query); } + [Theory(DisplayName = "The endpoint is served from the configured path")] + [InlineData("/socket.io", "/socket.io/")] + [InlineData("socket.io", "/socket.io/")] + [InlineData("/socket.io/", "/socket.io/")] + [InlineData("", "/")] + void Should_Serve_From_The_Configured_Path(string path, string expectedPath) + { + using var transport = new WebSocketTransport("http://example.com", "1NkM2QzZGMjEyMTIxCg", path); + + Assert.Equal(expectedPath, transport.Uri.AbsolutePath); + } + [Theory(DisplayName = "Required constructor arguments are rejected when missing")] [InlineData(null, "1NkM2QzZGMjEyMTIxCg")] [InlineData("", "1NkM2QzZGMjEyMTIxCg")] From 87f88b827ba9c7bf5734b5ff09b4963a03eb26dc Mon Sep 17 00:00:00 2001 From: redouane Date: Tue, 8 Sep 2026 00:36:28 +0200 Subject: [PATCH 02/21] feat(engine): let a layered protocol send its own packets Socket.IO encodes its own packets and has to say which Engine.IO packet carries the result, a decision the text and binary overloads make on the caller's behalf. It also encodes straight to bytes, so the new plain-text factory overload spares it a transcode through a string. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wpg9UqbFRzXvmpp2Msbieu --- src/EngineIO.Client/Engine.cs | 15 +++++++++++++++ src/EngineIO.Client/Packets/Packet.cs | 13 +++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/EngineIO.Client/Engine.cs b/src/EngineIO.Client/Engine.cs index 9559f14..b3598c5 100644 --- a/src/EngineIO.Client/Engine.cs +++ b/src/EngineIO.Client/Engine.cs @@ -311,6 +311,21 @@ public async IAsyncEnumerable ListenAsync([EnumeratorCancellation] Cance } } + /// + /// Send a packet as it stands, leaving its framing to the current transport. + /// + /// + /// A protocol layered on top of Engine.io does its own encoding and has to say + /// which Engine.io packet carries the result — a decision the text and binary + /// overloads make on the caller's behalf. + /// + /// Packet to send + /// + public async Task SendAsync(Packet packet, CancellationToken cancellationToken = default) + { + await _transport.SendAsync(packet, cancellationToken).ConfigureAwait(false); + } + /// /// Send plain text message. /// diff --git a/src/EngineIO.Client/Packets/Packet.cs b/src/EngineIO.Client/Packets/Packet.cs index 0332d49..69bb75d 100644 --- a/src/EngineIO.Client/Packets/Packet.cs +++ b/src/EngineIO.Client/Packets/Packet.cs @@ -57,6 +57,19 @@ public static Packet CreateMessagePacket(string text) return new Packet(PacketFormat.PlainText, PacketType.Message, body); } + /// + /// Wrap an already UTF-8 encoded body in a plain-text message packet. + /// + /// + /// A protocol layered on top of Engine.io — Socket.IO — encodes straight to + /// bytes, so routing it through + /// would transcode the same payload twice for nothing. + /// + public static Packet CreateMessagePacket(ReadOnlyMemory body) + { + return new Packet(PacketFormat.PlainText, PacketType.Message, body); + } + public static Packet CreateBinaryPacket(ReadOnlyMemory body) { return new Packet(PacketFormat.Binary, PacketType.Message, body); From a7e3675340432e14cd22cc51b8b1a3c1bc90762b Mon Sep 17 00:00:00 2001 From: redouane Date: Tue, 8 Sep 2026 00:36:34 +0200 Subject: [PATCH 03/21] chore(socketio): scaffold the Socket.IO client and test projects Both target net10.0 and carry no PackageReference of their own: everything they need is in the shared framework or in Directory.Build.props, and adding one back would trip NU1510. The test project mirrors EngineIO.Client.Tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wpg9UqbFRzXvmpp2Msbieu --- SocketIO.sln | 14 ++++++++++ src/SocketIO.Client/AssemblyInfo.cs | 3 ++ src/SocketIO.Client/SocketIO.Client.csproj | 11 ++++++++ tests/SocketIO.Client.Tests/GlobalUsings.cs | 1 + .../SocketIO.Client.Tests.csproj | 28 +++++++++++++++++++ 5 files changed, 57 insertions(+) create mode 100644 src/SocketIO.Client/AssemblyInfo.cs create mode 100644 src/SocketIO.Client/SocketIO.Client.csproj create mode 100644 tests/SocketIO.Client.Tests/GlobalUsings.cs create mode 100644 tests/SocketIO.Client.Tests/SocketIO.Client.Tests.csproj diff --git a/SocketIO.sln b/SocketIO.sln index aec678e..0397db5 100644 --- a/SocketIO.sln +++ b/SocketIO.sln @@ -15,6 +15,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{D8B5DE1E EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EngineIO.Client.Tests", "tests\EngineIO.Client.Tests\EngineIO.Client.Tests.csproj", "{5D824F05-5793-4768-99DF-E4D993F16B75}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SocketIO.Client", "src\SocketIO.Client\SocketIO.Client.csproj", "{222889F5-6A36-4A8A-8C78-A910E40D4F82}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SocketIO.Client.Tests", "tests\SocketIO.Client.Tests\SocketIO.Client.Tests.csproj", "{E0C636E7-91B0-44AE-9752-F08326C7C5F2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -36,10 +40,20 @@ Global {5D824F05-5793-4768-99DF-E4D993F16B75}.Debug|Any CPU.Build.0 = Debug|Any CPU {5D824F05-5793-4768-99DF-E4D993F16B75}.Release|Any CPU.ActiveCfg = Release|Any CPU {5D824F05-5793-4768-99DF-E4D993F16B75}.Release|Any CPU.Build.0 = Release|Any CPU + {222889F5-6A36-4A8A-8C78-A910E40D4F82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {222889F5-6A36-4A8A-8C78-A910E40D4F82}.Debug|Any CPU.Build.0 = Debug|Any CPU + {222889F5-6A36-4A8A-8C78-A910E40D4F82}.Release|Any CPU.ActiveCfg = Release|Any CPU + {222889F5-6A36-4A8A-8C78-A910E40D4F82}.Release|Any CPU.Build.0 = Release|Any CPU + {E0C636E7-91B0-44AE-9752-F08326C7C5F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E0C636E7-91B0-44AE-9752-F08326C7C5F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E0C636E7-91B0-44AE-9752-F08326C7C5F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E0C636E7-91B0-44AE-9752-F08326C7C5F2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {D1C745FE-52AF-4145-8243-9FCA91BE7916} = {80C612B0-98E1-4E6B-AF10-5BB3087F0864} {4F6177C5-3234-4897-B61C-49C44D3E2EF8} = {577735D4-BC7E-464A-BC9B-93AD8B5B6738} {5D824F05-5793-4768-99DF-E4D993F16B75} = {D8B5DE1E-C83F-40A9-9D27-D2FBB3866F0A} + {222889F5-6A36-4A8A-8C78-A910E40D4F82} = {577735D4-BC7E-464A-BC9B-93AD8B5B6738} + {E0C636E7-91B0-44AE-9752-F08326C7C5F2} = {D8B5DE1E-C83F-40A9-9D27-D2FBB3866F0A} EndGlobalSection EndGlobal diff --git a/src/SocketIO.Client/AssemblyInfo.cs b/src/SocketIO.Client/AssemblyInfo.cs new file mode 100644 index 0000000..ad574f7 --- /dev/null +++ b/src/SocketIO.Client/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("SocketIO.Client.Tests")] \ No newline at end of file diff --git a/src/SocketIO.Client/SocketIO.Client.csproj b/src/SocketIO.Client/SocketIO.Client.csproj new file mode 100644 index 0000000..f9ae9c2 --- /dev/null +++ b/src/SocketIO.Client/SocketIO.Client.csproj @@ -0,0 +1,11 @@ + + + + net10.0 + + + + + + + \ No newline at end of file diff --git a/tests/SocketIO.Client.Tests/GlobalUsings.cs b/tests/SocketIO.Client.Tests/GlobalUsings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/tests/SocketIO.Client.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/tests/SocketIO.Client.Tests/SocketIO.Client.Tests.csproj b/tests/SocketIO.Client.Tests/SocketIO.Client.Tests.csproj new file mode 100644 index 0000000..49e680f --- /dev/null +++ b/tests/SocketIO.Client.Tests/SocketIO.Client.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + SocketIO.Client.Tests + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + \ No newline at end of file From 6cd6bd9878915607d642d1de98170bdf4c773def Mon Sep 17 00:00:00 2001 From: redouane Date: Tue, 8 Sep 2026 00:36:43 +0200 Subject: [PATCH 04/21] feat(socketio): add the packet model and serializer PacketType values are the ASCII byte of the digit they are written as, the same trick the Engine.IO packet uses, so encoding a type is a cast. Serialization writes into a caller-owned buffer and holds no state of its own, which is what lets the shared Connect and Disconnect packets be sent more than once. Three details the format insists on: - a namespace is normalized to its leading-slash form on construction, so "admin" and "/admin" cannot encode two different ways; - an acknowledgement answers an event rather than naming one, so only the event types seed an event name into the payload; - binary never travels inside the JSON. Each attachment leaves a placeholder behind and is exposed for the caller to send separately. AddItem takes a byte array explicitly. Without that overload an array binds to the generic one and ships as a base64 Json string with no complaint. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wpg9UqbFRzXvmpp2Msbieu --- src/SocketIO.Client/Packets/Packet.cs | 312 ++++++++++++++++++++++ src/SocketIO.Client/Packets/PacketData.cs | 87 ++++++ src/SocketIO.Client/Packets/PacketType.cs | 47 ++++ 3 files changed, 446 insertions(+) create mode 100644 src/SocketIO.Client/Packets/Packet.cs create mode 100644 src/SocketIO.Client/Packets/PacketData.cs create mode 100644 src/SocketIO.Client/Packets/PacketType.cs diff --git a/src/SocketIO.Client/Packets/Packet.cs b/src/SocketIO.Client/Packets/Packet.cs new file mode 100644 index 0000000..102197e --- /dev/null +++ b/src/SocketIO.Client/Packets/Packet.cs @@ -0,0 +1,312 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Text; +using System.Text.Json; + +namespace SocketIO.Client.Packets; + +/// +/// Represent a Socket.IO packet. see: https://socket.io/docs/v4/socket-io-protocol +/// +/// +/// +/// The wire format is a header followed by a JSON payload: +/// <type>[<# of binary attachments>-][<namespace>,][<ack id>]<JSON payload>. +/// +/// +/// Binary arguments never appear in that JSON. Each leaves a placeholder behind +/// and travels as its own packet after the header — see . +/// +/// +public sealed class Packet +{ + /// + /// The namespace every connection starts in. + /// + public const string DefaultNamespace = "/"; + + /// + /// The event name used when the caller does not name one. + /// + public const string DefaultEventName = "message"; + + public static readonly Packet ConnectPacket = new(PacketType.Connect); + + public static readonly Packet DisconnectPacket = new(PacketType.Disconnect); + + private readonly List _data = new(); + + private readonly List> _attachments = new(); + + public Packet(PacketType type) + : this(type, null, null) + { + } + + public Packet(PacketType type, string? @namespace) + : this(type, @namespace, null) + { + } + + public Packet(PacketType type, string? @namespace, string? @event) + { + if (!Enum.IsDefined(type)) + { + throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown packet type."); + } + + if (@event is not null && !CarriesEventName(type)) + { + throw new ArgumentException($"A {type} packet does not carry an event name.", nameof(@event)); + } + + Type = type; + Namespace = NormalizeNamespace(@namespace); + + if (!CarriesEventName(type)) + { + return; + } + + // The event name is not header material: it is the first argument of the + // payload array, which is why it is seeded as an item like any other. + Event = @event ?? DefaultEventName; + _data.Add(new TextPacketData(Event)); + } + + public Packet(PacketType type, int ackId, string? @namespace, string? @event) + : this(type, @namespace, @event) + { + if (!CarriesAckId(type)) + { + throw new ArgumentException($"A {type} packet cannot carry an acknowledgement id.", nameof(type)); + } + + AckId = ackId; + } + + /// + /// Represents packet type. + /// + public PacketType Type { get; } + + /// + /// Namespace this packet belongs to, always in its leading-slash form. + /// + public string Namespace { get; } + + /// + /// Acknowledgement id correlating an event with its acknowledgement, when the + /// packet takes part in one. + /// + public int? AckId { get; } + + /// + /// Event name for the types that carry one, otherwise null. + /// + /// + /// An acknowledgement answers an event rather than naming one, so its payload + /// holds the response arguments alone. + /// + public string? Event { get; } + + /// + /// The binary arguments, in the order their placeholders reference them. Each + /// is sent as a separate binary packet after this one. + /// + public IReadOnlyList> Attachments => _attachments; + + /// + /// Add plain text data to packet. + /// + /// Plain text data + public void AddItem(string data) + { + AddPacketData(new TextPacketData(data)); + } + + /// + /// Add Json serializable POCO. + /// + /// Data instance + /// Data type + public void AddItem(T data) where T : class + { + AddPacketData(new JsonPacketData(data)); + } + + /// + /// Add binary data. + /// + /// + /// Present so that a byte array reaches the binary overload rather than + /// , which would quietly encode it as a base64 string. + /// + /// Binary data + public void AddItem(byte[] data) + { + AddItem(new ReadOnlyMemory(data)); + } + + /// + /// Add binary data. + /// + /// Binary data + public void AddItem(ReadOnlyMemory data) + { + if (Type is not (PacketType.BinaryEvent or PacketType.BinaryAck)) + { + throw new InvalidOperationException( + $"A {Type} packet cannot carry binary data; use {nameof(PacketType.BinaryEvent)} " + + $"or {nameof(PacketType.BinaryAck)}."); + } + + AddPacketData(new BinaryPacketData(_attachments.Count, data)); + _attachments.Add(data); + } + + /// + /// Append an argument to the payload. + /// + /// + /// Named apart from the public AddItem overloads on purpose: an + /// is a class, so an overload by that name would + /// bind to and recurse into itself. + /// + private void AddPacketData(IPacketData data) + { + if (Type is PacketType.Connect or PacketType.Disconnect) + { + throw new InvalidOperationException($"A {Type} packet does not carry a payload."); + } + + _data.Add(data); + } + + /// + /// Serialize the packet header and payload to their wire representation. + /// + /// + /// The result is the text part only. Anything in + /// follows it as separate packets. + /// + /// The encoded packet + internal ReadOnlyMemory Serialize() + { + var buffer = new ArrayBufferWriter(); + Serialize(buffer); + return buffer.WrittenMemory; + } + + /// + /// Serialize the packet into a caller-owned buffer. + /// + /// + /// Writing rather than returning keeps the packet stateless, so the same + /// instance — among them — can be sent repeatedly. + /// + internal void Serialize(IBufferWriter writer) + { + WriteHeader(writer); + WritePayload(writer); + } + + private void WriteHeader(IBufferWriter writer) + { + WriteByte(writer, (byte)Type); + + if (_attachments.Count > 0) + { + WriteInt32(writer, _attachments.Count); + WriteByte(writer, (byte)'-'); + } + + // The default namespace is implied by its absence. + if (!string.Equals(Namespace, DefaultNamespace, StringComparison.Ordinal)) + { + var length = Encoding.UTF8.GetByteCount(Namespace); + var span = writer.GetSpan(length + 1); + Encoding.UTF8.GetBytes(Namespace, span); + span[length] = (byte)','; + writer.Advance(length + 1); + } + + if (AckId.HasValue) + { + WriteInt32(writer, AckId.Value); + } + } + + private void WritePayload(IBufferWriter writer) + { + if (Type is PacketType.Connect or PacketType.Disconnect) + { + return; + } + + using var json = new Utf8JsonWriter(writer); + + // CONNECT_ERROR is the one payload that is not an argument list: it is the + // error object on its own. + if (Type == PacketType.ConnectError) + { + if (_data.Count > 0) + { + _data[0].Serialize(json); + } + + json.Flush(); + return; + } + + json.WriteStartArray(); + + foreach (var item in _data) + { + item.Serialize(json); + } + + json.WriteEndArray(); + json.Flush(); + } + + private static void WriteByte(IBufferWriter writer, byte value) + { + writer.GetSpan(1)[0] = value; + writer.Advance(1); + } + + private static void WriteInt32(IBufferWriter writer, int value) + { + // Room for every digit an int can produce, sign included. + var span = writer.GetSpan(11); + value.TryFormat(span, out var written); + writer.Advance(written); + } + + /// + /// Bring a namespace to the single form the wire uses, so that "admin", + /// "/admin" and a missing namespace do not encode three different ways. + /// + private static string NormalizeNamespace(string? @namespace) + { + if (string.IsNullOrWhiteSpace(@namespace)) + { + return DefaultNamespace; + } + + var trimmed = @namespace!.Trim(); + return trimmed.StartsWith('/') ? trimmed : "/" + trimmed; + } + + private static bool CarriesEventName(PacketType type) + { + return type is PacketType.Event or PacketType.BinaryEvent; + } + + private static bool CarriesAckId(PacketType type) + { + return type is PacketType.Event or PacketType.Ack or PacketType.BinaryEvent or PacketType.BinaryAck; + } +} \ No newline at end of file diff --git a/src/SocketIO.Client/Packets/PacketData.cs b/src/SocketIO.Client/Packets/PacketData.cs new file mode 100644 index 0000000..aa05329 --- /dev/null +++ b/src/SocketIO.Client/Packets/PacketData.cs @@ -0,0 +1,87 @@ +using System; +using System.Text.Json; + +namespace SocketIO.Client.Packets; + +/// +/// One argument of a packet payload, able to write itself into the payload array. +/// +/// +/// The payload is a JSON array of mixed arguments, so each argument owns how it is +/// written rather than the packet switching over shapes it does not know about. +/// +internal interface IPacketData +{ + void Serialize(Utf8JsonWriter writer); +} + +/// +/// A plain text argument. Also carries the event name, which is nothing more than +/// the first argument of an event payload. +/// +internal sealed class TextPacketData : IPacketData +{ + public TextPacketData(string data) + { + Data = data; + } + + public string Data { get; } + + public void Serialize(Utf8JsonWriter writer) + { + writer.WriteStringValue(Data); + } +} + +/// +/// An argument serialized from a POCO. +/// +internal sealed class JsonPacketData : IPacketData where T : class +{ + public JsonPacketData(T data) + { + Data = data; + } + + public T Data { get; } + + public void Serialize(Utf8JsonWriter writer) + { + // TODO: take a JsonTypeInfo so callers can supply a source-generated + // context, the way the Engine.io handshake already does. + JsonSerializer.Serialize(writer, Data); + } +} + +/// +/// The placeholder a binary argument leaves behind in the payload. +/// +/// +/// Binary never travels inside the JSON: the payload holds +/// {"_placeholder":true,"num":N} and the bytes follow the header as the +/// Nth separate binary packet. +/// +internal sealed class BinaryPacketData : IPacketData +{ + public BinaryPacketData(int id, ReadOnlyMemory data) + { + Id = id; + Data = data; + } + + public ReadOnlyMemory Data { get; } + + /// + /// Position of this attachment among the packet's binary arguments. + /// + public int Id { get; } + + public void Serialize(Utf8JsonWriter writer) + { + writer.WriteStartObject(); + writer.WriteBoolean("_placeholder", true); + writer.WriteNumber("num", Id); + writer.WriteEndObject(); + } +} \ No newline at end of file diff --git a/src/SocketIO.Client/Packets/PacketType.cs b/src/SocketIO.Client/Packets/PacketType.cs new file mode 100644 index 0000000..3784415 --- /dev/null +++ b/src/SocketIO.Client/Packets/PacketType.cs @@ -0,0 +1,47 @@ +namespace SocketIO.Client.Packets; + +/// +/// Represent Socket.IO protocol packet types. see: https://socket.io/docs/v4/socket-io-protocol +/// +/// +/// As with , each value is the +/// ASCII byte of the digit it is written as on the wire, so encoding a type is a +/// cast rather than a lookup. +/// +public enum PacketType : byte +{ + /// + /// Connect packet type. + /// + Connect = 0x30, + + /// + /// Disconnect packet type. + /// + Disconnect = 0x31, + + /// + /// Event packet type with plain text/JSON data. + /// + Event = 0x32, + + /// + /// Acknowledgement packet type with plain text/JSON data. + /// + Ack = 0x33, + + /// + /// Connection error packet type. + /// + ConnectError = 0x34, + + /// + /// Event packet type with binary data. + /// + BinaryEvent = 0x35, + + /// + /// Acknowledgement packet type with binary data. + /// + BinaryAck = 0x36 +} \ No newline at end of file From 87095d61dd08c6bdd9e67ccffe950ef18915bf0b Mon Sep 17 00:00:00 2001 From: redouane Date: Tue, 8 Sep 2026 00:36:49 +0200 Subject: [PATCH 05/21] test(socketio): cover packet construction and encoding The expected wire strings are the specification these tests exist to pin down, including the cases the format is easy to get wrong: a namespace however it was spelled, an acknowledgement payload without an event name, attachment numbering, text and binary mixed in one payload, a byte array staying binary, and the same packet encoding identically twice. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wpg9UqbFRzXvmpp2Msbieu --- .../Packets/BinaryEventPacketTests.cs | 150 +++++++++++++ .../Packets/ConnectPacketTests.cs | 69 ++++++ .../Packets/DisconnectPacketTests.cs | 59 +++++ .../Packets/EventPacketTests.cs | 208 ++++++++++++++++++ 4 files changed, 486 insertions(+) create mode 100644 tests/SocketIO.Client.Tests/Packets/BinaryEventPacketTests.cs create mode 100644 tests/SocketIO.Client.Tests/Packets/ConnectPacketTests.cs create mode 100644 tests/SocketIO.Client.Tests/Packets/DisconnectPacketTests.cs create mode 100644 tests/SocketIO.Client.Tests/Packets/EventPacketTests.cs diff --git a/tests/SocketIO.Client.Tests/Packets/BinaryEventPacketTests.cs b/tests/SocketIO.Client.Tests/Packets/BinaryEventPacketTests.cs new file mode 100644 index 0000000..71e8215 --- /dev/null +++ b/tests/SocketIO.Client.Tests/Packets/BinaryEventPacketTests.cs @@ -0,0 +1,150 @@ +using System.Text; + +using SocketIO.Client.Packets; + +namespace SocketIO.Client.Tests.Packets; + +public class BinaryEventPacketTests +{ + [Fact] + void ShouldCreateBinaryEventPacket() + { + var packet = new Packet(PacketType.BinaryEvent); + packet.AddItem(new ReadOnlyMemory([1, 2, 3])); + + Assert.Equal(PacketType.BinaryEvent, packet.Type); + Assert.Equal("/", packet.Namespace); + } + + [Fact] + public void ShouldCreateBinaryPacketWithNamespace() + { + var @namespace = "test"; + + var packet = new Packet(PacketType.BinaryEvent, @namespace); + + Assert.Equal($"/{@namespace}", packet.Namespace); + } + + [Fact] + public void ShouldCreateBinaryPacketWithEventName() + { + var eventName = "test"; + + var packet = new Packet(PacketType.BinaryEvent, null, eventName); + + Assert.Equal(eventName, packet.Event); + } + + [Fact] + public void ShouldCreateBinaryPacketWithAckId() + { + var ackId = 42; + + var packet = new Packet(PacketType.BinaryAck, ackId, null, null); + + Assert.Equal(ackId, packet.AckId); + } + + [Fact] + public void ShouldSerializeBinaryEventPacket() + { + var packet = new Packet(PacketType.BinaryEvent); + packet.AddItem(new ReadOnlyMemory([1, 2, 3])); + + var encodedPacket = Encoding.UTF8.GetString(packet.Serialize().Span); + + Assert.Equal("""51-["message",{"_placeholder":true,"num":0}]""", encodedPacket); + } + + [Fact] + public void ShouldSerializeBinaryPacketWithNamespace() + { + var @namespace = "test"; + var expectedEncodedPacket = $$"""51-/{{@namespace}},["message",{"_placeholder":true,"num":0}]"""; + + var packet = new Packet(PacketType.BinaryEvent, @namespace); + packet.AddItem(new ReadOnlyMemory([1, 2, 3])); + + Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); + } + + [Fact] + public void ShouldSerializeBinaryPacketWithEventName() + { + var eventName = "test"; + var expectedEncodedPacket = $$"""51-["{{eventName}}",{"_placeholder":true,"num":0}]"""; + + var packet = new Packet(PacketType.BinaryEvent, null, eventName); + packet.AddItem(new ReadOnlyMemory([1, 2, 3])); + + Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); + } + + [Fact] + public void ShouldSerializeBinaryPacketWithAckId() + { + var ackId = 42; + + // An acknowledgement answers an event rather than naming one. + var expectedEncodedPacket = $$"""61-{{ackId}}[{"_placeholder":true,"num":0}]"""; + + var packet = new Packet(PacketType.BinaryAck, ackId, null, null); + packet.AddItem(new ReadOnlyMemory([1, 2, 3])); + + Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); + } + + [Fact(DisplayName = "Attachments are numbered in the order they were added")] + public void ShouldNumberAttachmentsInOrder() + { + var first = new ReadOnlyMemory([1, 2, 3]); + var second = new ReadOnlyMemory([4, 5, 6]); + var expectedEncodedPacket = + """52-["message",{"_placeholder":true,"num":0},{"_placeholder":true,"num":1}]"""; + + var packet = new Packet(PacketType.BinaryEvent); + packet.AddItem(first); + packet.AddItem(second); + + Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); + Assert.Equal([first, second], packet.Attachments); + } + + [Fact(DisplayName = "Binary and text arguments can be mixed in one payload")] + public void ShouldMixTextAndBinaryArguments() + { + var expectedEncodedPacket = """51-["message","Hello!",{"_placeholder":true,"num":0}]"""; + + var packet = new Packet(PacketType.BinaryEvent); + packet.AddItem("Hello!"); + packet.AddItem(new ReadOnlyMemory([1, 2, 3])); + + Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); + } + + [Fact(DisplayName = "A byte array is treated as binary, not as a Json value")] + public void ShouldTreatAByteArrayAsBinary() + { + byte[] attachment = [1, 2, 3]; + + var packet = new Packet(PacketType.BinaryEvent); + packet.AddItem(attachment); + + Assert.Equal("""51-["message",{"_placeholder":true,"num":0}]""", + Encoding.UTF8.GetString(packet.Serialize().Span)); + Assert.Equal(attachment, Assert.Single(packet.Attachments)); + } + + [Fact(DisplayName = "The attachments are kept out of the encoded header")] + public void ShouldKeepAttachmentsOutOfTheHeader() + { + var attachment = new ReadOnlyMemory([1, 2, 3]); + + var packet = new Packet(PacketType.BinaryEvent); + packet.AddItem(attachment); + + Assert.Equal(attachment, Assert.Single(packet.Attachments)); + Assert.DoesNotContain((byte)0x01, packet.Serialize().Span.ToArray()); + } +} \ No newline at end of file diff --git a/tests/SocketIO.Client.Tests/Packets/ConnectPacketTests.cs b/tests/SocketIO.Client.Tests/Packets/ConnectPacketTests.cs new file mode 100644 index 0000000..9eb39b2 --- /dev/null +++ b/tests/SocketIO.Client.Tests/Packets/ConnectPacketTests.cs @@ -0,0 +1,69 @@ +using System.Text; + +using SocketIO.Client.Packets; + +namespace SocketIO.Client.Tests.Packets; + +public class ConnectPacketTests +{ + [Fact] + void ShouldCreateConnectPacket() + { + var packet = Packet.ConnectPacket; + + Assert.Equal(PacketType.Connect, packet.Type); + Assert.Equal("/", packet.Namespace); + } + + [Fact] + void ShouldCreateConnectPacketWithNamespace() + { + var @namespace = "test"; + + var packet = new Packet(PacketType.Connect, @namespace); + + Assert.Equal(PacketType.Connect, packet.Type); + Assert.Equal($"/{@namespace}", packet.Namespace); + } + + [Fact] + void ShouldSerializeConnectPacket() + { + var connectPacket = Packet.ConnectPacket; + + var encodedPacket = Encoding.UTF8.GetString(connectPacket.Serialize().Span); + + Assert.Equal(PacketType.Connect, connectPacket.Type); + Assert.Equal("0", encodedPacket); + } + + [Fact] + void ShouldSerializeConnectPacketWithNamespace() + { + var @namespace = "test"; + var connectPacket = new Packet(PacketType.Connect, @namespace); + + var encodedPacket = Encoding.UTF8.GetString(connectPacket.Serialize().Span); + + Assert.Equal(PacketType.Connect, connectPacket.Type); + Assert.Equal($"0/{@namespace},", encodedPacket); + } + + [Fact] + void ShouldThrowExceptionWhenAddingItemToConnectPacket() + { + var packet = Packet.ConnectPacket; + + Assert.Throws(() => packet.AddItem("World")); + } + + [Fact(DisplayName = "The shared Connect packet can be serialized more than once")] + void ShouldSerializeSharedConnectPacketRepeatedly() + { + var first = Encoding.UTF8.GetString(Packet.ConnectPacket.Serialize().Span); + var second = Encoding.UTF8.GetString(Packet.ConnectPacket.Serialize().Span); + + Assert.Equal("0", first); + Assert.Equal(first, second); + } +} \ No newline at end of file diff --git a/tests/SocketIO.Client.Tests/Packets/DisconnectPacketTests.cs b/tests/SocketIO.Client.Tests/Packets/DisconnectPacketTests.cs new file mode 100644 index 0000000..dd93a0f --- /dev/null +++ b/tests/SocketIO.Client.Tests/Packets/DisconnectPacketTests.cs @@ -0,0 +1,59 @@ +using System.Text; + +using SocketIO.Client.Packets; + +namespace SocketIO.Client.Tests.Packets; + +public class DisconnectPacketTests +{ + [Fact] + void ShouldCreateDisconnectPacket() + { + var packet = Packet.DisconnectPacket; + + Assert.Equal(PacketType.Disconnect, packet.Type); + Assert.Equal("/", packet.Namespace); + } + + [Fact] + void ShouldCreateDisconnectPacketWithNamespace() + { + var @namespace = "test"; + + var packet = new Packet(PacketType.Disconnect, @namespace); + + Assert.Equal(PacketType.Disconnect, packet.Type); + Assert.Equal($"/{@namespace}", packet.Namespace); + } + + [Fact] + void ShouldSerializeDisconnectPacket() + { + var packet = Packet.DisconnectPacket; + + var encodedPacket = Encoding.UTF8.GetString(packet.Serialize().Span); + + Assert.Equal(PacketType.Disconnect, packet.Type); + Assert.Equal("1", encodedPacket); + } + + [Fact] + void ShouldSerializeDisconnectPacketWithNamespace() + { + var @namespace = "test"; + var packet = new Packet(PacketType.Disconnect, @namespace); + + var encodedPacket = Encoding.UTF8.GetString(packet.Serialize().Span); + + Assert.Equal(PacketType.Disconnect, packet.Type); + Assert.Equal($"1/{@namespace},", encodedPacket); + } + + [Fact] + void ShouldThrowExceptionWhenAddingItemToDisconnectPacket() + { + var packet = Packet.DisconnectPacket; + + Assert.Throws(() => packet.AddItem("World")); + } +} \ No newline at end of file diff --git a/tests/SocketIO.Client.Tests/Packets/EventPacketTests.cs b/tests/SocketIO.Client.Tests/Packets/EventPacketTests.cs new file mode 100644 index 0000000..3e39cca --- /dev/null +++ b/tests/SocketIO.Client.Tests/Packets/EventPacketTests.cs @@ -0,0 +1,208 @@ +using System.Text; + +using SocketIO.Client.Packets; + +namespace SocketIO.Client.Tests.Packets; + +// class used for packet with json payload tests +class Foo +{ + public string? Value { get; set; } +} + +public class EventPacketTests +{ + [Fact] + void ShouldCreateEventPacket() + { + var packet = new Packet(PacketType.Event); + + Assert.Equal(PacketType.Event, packet.Type); + Assert.Equal("/", packet.Namespace); + Assert.Equal("message", packet.Event); + } + + [Fact] + void ShouldCreateEventPacketWithNamespace() + { + var @namespace = "test"; + + var packet = new Packet(PacketType.Event, @namespace); + + Assert.Equal(PacketType.Event, packet.Type); + Assert.Equal($"/{@namespace}", packet.Namespace); + Assert.Equal("message", packet.Event); + } + + [Theory(DisplayName = "A namespace encodes the same however it was spelled")] + [InlineData("test")] + [InlineData("/test")] + void ShouldNormalizeNamespace(string @namespace) + { + var packet = new Packet(PacketType.Event, @namespace); + packet.AddItem("Hello!"); + + Assert.Equal("/test", packet.Namespace); + Assert.Equal("""2/test,["message","Hello!"]""", Encoding.UTF8.GetString(packet.Serialize().Span)); + } + + [Fact] + void ShouldCreateEventPacketWithEventName() + { + var eventName = "test"; + + var packet = new Packet(PacketType.Event, null, eventName); + + Assert.Equal(PacketType.Event, packet.Type); + Assert.Equal("/", packet.Namespace); + Assert.Equal(eventName, packet.Event); + } + + [Fact] + void ShouldCreateEventPacketWithAckId() + { + var ackId = 42; + + var packet = new Packet(PacketType.Ack, ackId, null, null); + + Assert.Equal(PacketType.Ack, packet.Type); + Assert.Equal(ackId, packet.AckId); + } + + [Fact(DisplayName = "An event can request an acknowledgement")] + void ShouldCreateEventPacketRequestingAnAcknowledgement() + { + var ackId = 7; + + var packet = new Packet(PacketType.Event, ackId, null, null); + packet.AddItem("Hello!"); + + Assert.Equal(ackId, packet.AckId); + Assert.Equal("""27["message","Hello!"]""", Encoding.UTF8.GetString(packet.Serialize().Span)); + } + + [Fact(DisplayName = "An acknowledgement does not carry an event name")] + void ShouldRejectAnEventNameOnAnAcknowledgement() + { + Assert.Throws(() => new Packet(PacketType.Ack, null, "test")); + } + + [Fact] + void ShouldSerializePlainTextEventPacket() + { + var packet = new Packet(PacketType.Event); + packet.AddItem("Hello!"); + + var encodedPacket = Encoding.UTF8.GetString(packet.Serialize().Span); + + Assert.Equal("""2["message","Hello!"]""", encodedPacket); + } + + [Fact] + void ShouldThrowWhenAddingInvalidPayloadPacket() + { + var packet = new Packet(PacketType.Event); + var invalidPayload = new ReadOnlyMemory(new byte[] { 1, 2, 3 }); + + Assert.Throws(() => packet.AddItem(invalidPayload)); + } + + [Fact] + void ShouldSerializePlainTextEventWithNamespace() + { + var @namespace = "test"; + var expectedEncodedPacket = $"""2/{@namespace},["message","Hello!"]"""; + + var packet = new Packet(PacketType.Event, @namespace); + packet.AddItem("Hello!"); + + Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); + } + + [Fact] + void ShouldSerializePlainTextEventWithEventName() + { + var eventName = "test"; + var expectedEncodedPacket = $$"""2["{{eventName}}","Hello!"]"""; + + var packet = new Packet(PacketType.Event, null, eventName); + packet.AddItem("Hello!"); + + Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); + } + + [Fact] + void ShouldSerializePlainTextWithAckIdPacket() + { + var ackId = 42; + + // An acknowledgement answers an event rather than naming one, so its payload + // is the response arguments alone. + var expectedEncodedPacket = $$"""3{{ackId}}["Hello!"]"""; + + var packet = new Packet(PacketType.Ack, ackId, null, null); + packet.AddItem("Hello!"); + + Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); + } + + [Fact] + void ShouldSerializeJsonEventPacket() + { + var packet = new Packet(PacketType.Event); + packet.AddItem(new Foo { Value = "bar" }); + + var encodedPacket = Encoding.UTF8.GetString(packet.Serialize().Span); + + Assert.Equal("""2["message",{"Value":"bar"}]""", encodedPacket); + } + + [Fact] + void ShouldSerializeJsonEventPacketWithNamespace() + { + var @namespace = "test"; + var expectedEncodedPacket = $$"""2/{{@namespace}},["message",{"Value":"bar"}]"""; + + var packet = new Packet(PacketType.Event, @namespace); + packet.AddItem(new Foo { Value = "bar" }); + + Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); + } + + [Fact] + void ShouldSerializeJsonEventWithEventName() + { + var eventName = "test"; + var expectedEncodedPacket = $$"""2["{{eventName}}",{"Value":"bar"}]"""; + + var packet = new Packet(PacketType.Event, null, eventName); + packet.AddItem(new Foo { Value = "bar" }); + + Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); + } + + [Fact] + void ShouldSerializeJsonEventWithAckIdPacket() + { + var ackId = 42; + var expectedEncodedPacket = $$"""3{{ackId}}[{"Value":"bar"}]"""; + + var packet = new Packet(PacketType.Ack, ackId, null, null); + packet.AddItem(new Foo { Value = "bar" }); + + Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); + } + + [Fact(DisplayName = "The same packet encodes identically every time it is sent")] + void ShouldSerializeTheSamePacketRepeatedly() + { + var packet = new Packet(PacketType.Event); + packet.AddItem("Hello!"); + + var first = Encoding.UTF8.GetString(packet.Serialize().Span); + var second = Encoding.UTF8.GetString(packet.Serialize().Span); + + Assert.Equal("""2["message","Hello!"]""", first); + Assert.Equal(first, second); + } +} \ No newline at end of file From 12241d0cf78472234b76f3b21c31176307b31129 Mon Sep 17 00:00:00 2001 From: redouane Date: Tue, 8 Sep 2026 00:36:56 +0200 Subject: [PATCH 06/21] feat(socketio): add the client surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sending is complete: the header goes out as a plain-text Engine.IO message and each attachment follows as its own binary message, in the order its placeholder named it. Receiving is not. ListenAsync says so rather than yielding something it cannot produce, and the namespace map it would fill is waiting on the same missing piece — nothing parses an inbound packet yet. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wpg9UqbFRzXvmpp2Msbieu --- src/SocketIO.Client/IO.cs | 185 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 src/SocketIO.Client/IO.cs diff --git a/src/SocketIO.Client/IO.cs b/src/SocketIO.Client/IO.cs new file mode 100644 index 0000000..0ffb79d --- /dev/null +++ b/src/SocketIO.Client/IO.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using EngineIO.Client; + +using Microsoft.Extensions.Logging; + +using SocketIO.Client.Packets; + +using EnginePacket = EngineIO.Client.Packets.Packet; + +namespace SocketIO.Client; + +/// +/// Socket.IO client, multiplexing namespaces over a single Engine.io connection. +/// +public sealed class IO : IAsyncDisposable +{ + /// + /// Path a Socket.IO server serves Engine.io from. + /// + public const string DefaultPath = "/socket.io"; + + private readonly Engine _client; + + /// + /// Map namespace with its corresponding sid. + /// + private readonly Dictionary _namespaces = new(); + + /// + /// Whether the underlying Engine.io connection has been established, tracked + /// here because has no transport to answer for + /// until it has. + /// + private bool _connected; + + public IO(string baseAddress, string path = DefaultPath, ILoggerFactory? loggerFactory = null) + { + Path = path; + + _client = new Engine(options => + { + options.BaseAddress = baseAddress; + options.Path = path; + options.AutoUpgrade = true; + // TODO: allow passing custom headers and queries + }, loggerFactory); + } + + /// + /// Path the server is served from. + /// + public string Path { get; } + + public async ValueTask DisposeAsync() + { + await _client.DisposeAsync().ConfigureAwait(false); + } + + /// + /// Connect to a namespace, opening the Engine.io connection if it is the first. + /// + /// Namespace to join, or the default one + /// + public async Task ConnectAsync(string? @namespace = default, CancellationToken cancellationToken = default) + { + if (!_connected) + { + await _client.ConnectAsync(cancellationToken).ConfigureAwait(false); + _connected = true; + } + + // TODO: the server answers with `0{"sid":"..."}` for the namespace, which is + // what _namespaces is waiting for. Recording it needs the inbound parser. + await SendPacketAsync(new Packet(PacketType.Connect, @namespace), cancellationToken).ConfigureAwait(false); + } + + /// + /// Leave a namespace. + /// + /// Namespace to leave, or the default one + /// + public async Task DisconnectAsync(string? @namespace = default, CancellationToken cancellationToken = default) + { + var packet = new Packet(PacketType.Disconnect, @namespace); + await SendPacketAsync(packet, cancellationToken).ConfigureAwait(false); + _namespaces.Remove(packet.Namespace); + } + + /// + /// Listen for incoming packets on a namespace. + /// + /// Namespace to listen on, or the default one + /// IAsyncEnumerable cancellation token + /// Packets + public IAsyncEnumerable ListenAsync( + string? @namespace = default, CancellationToken cancellationToken = default) + { + // TODO: decoding a Socket.IO packet from the Engine.io message stream, holding + // a binary header back until its attachments have arrived, and routing the + // result to the listener of the namespace it names. + throw new NotImplementedException( + "Receiving requires the Socket.IO packet parser, which is not implemented yet."); + } + + /// + /// Send plain text data. + /// + /// Plain text data + /// Event name, or the default one + /// Namespace to send on, or the default one + /// + public Task SendAsync(string text, string? @event = default, string? @namespace = default, + CancellationToken cancellationToken = default) + { + var packet = new Packet(PacketType.Event, @namespace, @event); + packet.AddItem(text); + return SendPacketAsync(packet, cancellationToken); + } + + /// + /// Send a Json serializable POCO. + /// + /// Data instance + /// Event name, or the default one + /// Namespace to send on, or the default one + /// + /// Data type + public Task SendAsync(T data, string? @event = default, string? @namespace = default, + CancellationToken cancellationToken = default) where T : class + { + var packet = new Packet(PacketType.Event, @namespace, @event); + packet.AddItem(data); + return SendPacketAsync(packet, cancellationToken); + } + + /// + /// Send binary data. + /// + /// + /// Present so that a byte array reaches the binary overload rather than + /// , which would quietly encode it as a base64 string. + /// + /// Binary data + /// Event name, or the default one + /// Namespace to send on, or the default one + /// + public Task SendAsync(byte[] data, string? @event = default, string? @namespace = default, + CancellationToken cancellationToken = default) + { + return SendAsync(new ReadOnlyMemory(data), @event, @namespace, cancellationToken); + } + + /// + /// Send binary data. + /// + /// Binary data + /// Event name, or the default one + /// Namespace to send on, or the default one + /// + public Task SendAsync(ReadOnlyMemory data, string? @event = default, string? @namespace = default, + CancellationToken cancellationToken = default) + { + var packet = new Packet(PacketType.BinaryEvent, @namespace, @event); + packet.AddItem(data); + return SendPacketAsync(packet, cancellationToken); + } + + private async Task SendPacketAsync(Packet packet, CancellationToken cancellationToken) + { + // The header travels as a plain-text Engine.io message; each attachment then + // follows as its own binary message, in the order its placeholder named it. + await _client.SendAsync(EnginePacket.CreateMessagePacket(packet.Serialize()), cancellationToken) + .ConfigureAwait(false); + + foreach (var attachment in packet.Attachments) + { + await _client.SendAsync(EnginePacket.CreateBinaryPacket(attachment), cancellationToken) + .ConfigureAwait(false); + } + } +} \ No newline at end of file From c19e05cf26d08760c2dff91130350efe15eb499d Mon Sep 17 00:00:00 2001 From: redouane Date: Tue, 8 Sep 2026 00:37:01 +0200 Subject: [PATCH 07/21] chore(samples): add a Socket.IO test server A second npm workspace beside the Engine.IO one, listening on 9855 so both can run at once, echoing "message" events and acknowledging them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wpg9UqbFRzXvmpp2Msbieu --- package-lock.json | 197 ++++++++++++++----- package.json | 6 +- samples/simple-socket-io-server/index.js | 17 ++ samples/simple-socket-io-server/package.json | 15 ++ 4 files changed, 185 insertions(+), 50 deletions(-) create mode 100644 samples/simple-socket-io-server/index.js create mode 100644 samples/simple-socket-io-server/package.json diff --git a/package-lock.json b/package-lock.json index 5bed53d..29f1ccd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,40 +9,47 @@ "version": "1.0.0", "license": "ISC", "workspaces": [ - "samples/simple-engine-io-server" + "samples/simple-engine-io-server", + "samples/simple-socket-io-server" ] }, - "node_modules/simple-engine-io-server": { - "resolved": "samples/simple-engine-io-server", - "link": true + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" }, - "samples/simple-engine-io-server": { - "version": "1.0.0", - "license": "ISC", + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", "dependencies": { - "engine.io": "^6.5.4" + "@types/node": "*" } }, - "samples/simple-engine-io-server/node_modules/@types/cookie": { - "version": "0.4.1", - "license": "MIT" - }, - "samples/simple-engine-io-server/node_modules/@types/cors": { - "version": "2.8.17", + "node_modules/@types/node": { + "version": "26.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.0.tgz", + "integrity": "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A==", "license": "MIT", "dependencies": { - "@types/node": "*" + "undici-types": "~8.9.0" } }, - "samples/simple-engine-io-server/node_modules/@types/node": { - "version": "20.11.20", + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "@types/node": "*" } }, - "samples/simple-engine-io-server/node_modules/accepts": { + "node_modules/accepts": { "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "license": "MIT", "dependencies": { "mime-types": "~2.1.34", @@ -52,22 +59,28 @@ "node": ">= 0.6" } }, - "samples/simple-engine-io-server/node_modules/base64id": { + "node_modules/base64id": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", "license": "MIT", "engines": { "node": "^4.5.0 || >= 5.9" } }, - "samples/simple-engine-io-server/node_modules/cookie": { - "version": "0.4.2", + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", "engines": { "node": ">= 0.6" } }, - "samples/simple-engine-io-server/node_modules/cors": { - "version": "2.8.5", + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", "dependencies": { "object-assign": "^4", @@ -75,13 +88,19 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "samples/simple-engine-io-server/node_modules/debug": { - "version": "4.3.4", + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -92,41 +111,48 @@ } } }, - "samples/simple-engine-io-server/node_modules/engine.io": { - "version": "6.5.4", + "node_modules/engine.io": { + "version": "6.6.10", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.10.tgz", + "integrity": "sha512-9/lX2bdlizlCXMHRMOIm03VBQHQYC7VvydcxtTAUJRxNW1QzM/2PMFSmr6h/lCiMHcyCP6abK+t9Q+j4vekk8Q==", "license": "MIT", "dependencies": { - "@types/cookie": "^0.4.1", "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", "accepts": "~1.3.4", - "base64id": "2.0.0", - "cookie": "~0.4.1", + "cookie": "~0.7.2", "cors": "~2.8.5", - "debug": "~4.3.1", + "debug": "~4.4.1", "engine.io-parser": "~5.2.1", - "ws": "~8.11.0" + "ws": "~8.21.0" }, "engines": { "node": ">=10.2.0" } }, - "samples/simple-engine-io-server/node_modules/engine.io-parser": { - "version": "5.2.2", + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", "license": "MIT", "engines": { "node": ">=10.0.0" } }, - "samples/simple-engine-io-server/node_modules/mime-db": { + "node_modules/mime-db": { "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", "engines": { "node": ">= 0.6" } }, - "samples/simple-engine-io-server/node_modules/mime-types": { + "node_modules/mime-types": { "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -135,44 +161,105 @@ "node": ">= 0.6" } }, - "samples/simple-engine-io-server/node_modules/ms": { - "version": "2.1.2", + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "samples/simple-engine-io-server/node_modules/negotiator": { + "node_modules/negotiator": { "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "license": "MIT", "engines": { "node": ">= 0.6" } }, - "samples/simple-engine-io-server/node_modules/object-assign": { + "node_modules/object-assign": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "samples/simple-engine-io-server/node_modules/undici-types": { - "version": "5.26.5", + "node_modules/simple-engine-io-server": { + "resolved": "samples/simple-engine-io-server", + "link": true + }, + "node_modules/simple-socket-io-server": { + "resolved": "samples/simple-socket-io-server", + "link": true + }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.21.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "license": "MIT" }, - "samples/simple-engine-io-server/node_modules/vary": { + "node_modules/vary": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", "license": "MIT", "engines": { "node": ">= 0.8" } }, - "samples/simple-engine-io-server/node_modules/ws": { - "version": "8.11.0", + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", "engines": { "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -182,6 +269,20 @@ "optional": true } } + }, + "samples/simple-engine-io-server": { + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "engine.io": "^6.5.4" + } + }, + "samples/simple-socket-io-server": { + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "socket.io": "^4.8.1" + } } } } diff --git a/package.json b/package.json index 4ec4c88..9f42cec 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,13 @@ "description": "Socket.IO .NET client samples test server and tools", "directories": {}, "scripts": { - "start:server": "npm start -w samples/simple-engine-io-server" + "start:server": "npm start -w samples/simple-engine-io-server", + "start:socket-server": "npm start -w samples/simple-socket-io-server" }, "author": "Redhouane Sobaihi", "license": "ISC", "workspaces": [ - "samples/simple-engine-io-server" + "samples/simple-engine-io-server", + "samples/simple-socket-io-server" ] } diff --git a/samples/simple-socket-io-server/index.js b/samples/simple-socket-io-server/index.js new file mode 100644 index 0000000..c0008d0 --- /dev/null +++ b/samples/simple-socket-io-server/index.js @@ -0,0 +1,17 @@ +const { Server } = require('socket.io'); + +const io = new Server({ + cors: { + origin: '*', + methods: ['GET', 'POST'] + }, +}); + +io.on('connection', (socket) => { + socket.on('message', (data, callback) => { + console.log(data); + callback?.apply(this, ['OK']); + }); +}); + +io.listen(Number(process.env.PORT) || 9855); diff --git a/samples/simple-socket-io-server/package.json b/samples/simple-socket-io-server/package.json new file mode 100644 index 0000000..dda9720 --- /dev/null +++ b/samples/simple-socket-io-server/package.json @@ -0,0 +1,15 @@ +{ + "name": "simple-socket-io-server", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "start": "DEBUG=engine,socket.io* node index.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "Socket.IO test server for the .NET client samples", + "dependencies": { + "socket.io": "^4.8.1" + } +} From 93ffcdbc5768ccbece35cd0f1b228a68e5e82379 Mon Sep 17 00:00:00 2001 From: redouane Date: Tue, 8 Sep 2026 00:37:06 +0200 Subject: [PATCH 08/21] docs: record the state of the Socket.IO layer Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wpg9UqbFRzXvmpp2Msbieu --- CLAUDE.md | 5 +++-- README.md | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fda889d..2cbe311 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,8 +9,8 @@ Task-based async (`Task`, `IAsyncEnumerable`, `Channel`) rather than the even .NET Socket.IO clients. Prefer an `await`-able or `await foreach`-able API over an `event` when adding surface area. The Engine.IO layer is implemented (HTTP polling transport, WebSocket transport with upgrade, plain-text and binary -packets). The Socket.IO layer on top of it — namespaces, payload send/receive — is not yet written; see the TODOs in -`README.md`. +packets). The Socket.IO layer on top of it is half written: `src/SocketIO.Client` encodes packets and sends them, but +nothing parses an inbound one yet, so namespaces and acknowledgements are still open. See the TODOs in `README.md`. Protocol references: - Engine.IO: https://socket.io/docs/v4/engine-io-protocol @@ -33,6 +33,7 @@ Running the sample end to end needs the Node test server, which lives in an npm ```bash npm install # once, from the repo root npm run start:server # Engine.IO server on http://127.0.0.1:9854 (scripts/run-server.sh wraps this) +npm run start:socket-server # Socket.IO server on http://127.0.0.1:9855 dotnet run --project samples/PingPong ``` diff --git a/README.md b/README.md index ab592f7..eba9b85 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,11 @@ The client implements `Engine.IO` and `Socket.IO` core protocols. - **Socket.IO Client** +- [x] Packet model and wire-format serialization (events, acks, binary attachments) +- [x] Send plain text, JSON and binary payloads +- [ ] Packet parsing (receiving) - [ ] Namespaces support -- [ ] Send and receive plain text and binary payloads +- [ ] Acknowledgement correlation ## Resources From bf021f0384243420a006bbf5eb35375d52dc7579 Mon Sep 17 00:00:00 2001 From: redouane Date: Tue, 8 Sep 2026 20:03:51 +0200 Subject: [PATCH 09/21] fix(engine): keep the upgrade URI off a doubled slash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WebSocket URI is built by concatenating the base address with the normalized path, and that path already opens with a slash. A base address the caller ended with one — "http://host/" — produced "//socket.io/", which a server matching on the start of the request path does not recognise: it answers 404. Long-polling was immune, because it resolves a relative Uri against HttpClient.BaseAddress and that normalizes, so the failure showed up as the confusing "polling works, the upgrade silently doesn't". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CsxPBM9F82FBXVQDV1jFmN --- .../Transports/WebSocketTransport.cs | 6 +++++- .../Transports/WebSocketTransportTests.cs | 13 +++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/EngineIO.Client/Transports/WebSocketTransport.cs b/src/EngineIO.Client/Transports/WebSocketTransport.cs index 61377ff..b6dded4 100644 --- a/src/EngineIO.Client/Transports/WebSocketTransport.cs +++ b/src/EngineIO.Client/Transports/WebSocketTransport.cs @@ -63,7 +63,11 @@ internal WebSocketTransport(IWebSocket client, string baseAddress, string sid, baseAddress = baseAddress.Replace("https://", "wss://"); } - var uri = $"{baseAddress}{TransportPath.Normalize(path)}?EIO={_protocol}&transport={Name}&sid={sid}"; + // The normalized path already opens with a slash, so one left on the base + // address would produce "//engine.io/", which a server matching on the start + // of the request path does not recognise. + var uri = $"{baseAddress.TrimEnd('/')}{TransportPath.Normalize(path)}" + + $"?EIO={_protocol}&transport={Name}&sid={sid}"; _uri = new Uri(uri); } diff --git a/tests/EngineIO.Client.Tests/Transports/WebSocketTransportTests.cs b/tests/EngineIO.Client.Tests/Transports/WebSocketTransportTests.cs index cabaf1c..d5df949 100644 --- a/tests/EngineIO.Client.Tests/Transports/WebSocketTransportTests.cs +++ b/tests/EngineIO.Client.Tests/Transports/WebSocketTransportTests.cs @@ -35,6 +35,19 @@ void Should_Serve_From_The_Configured_Path(string path, string expectedPath) Assert.Equal(expectedPath, transport.Uri.AbsolutePath); } + [Theory(DisplayName = "A slash on the base address is not doubled by the path")] + [InlineData("http://example.com", "/socket.io/")] + [InlineData("http://example.com/", "/socket.io/")] + [InlineData("http://example.com//", "/socket.io/")] + void Should_Not_Double_The_Slash_Between_Base_Address_And_Path(string baseAddress, string expectedPath) + { + // A server matches on the start of the request path, so "//socket.io/" is a + // path it does not recognise rather than a tidier spelling of the same one. + using var transport = new WebSocketTransport(baseAddress, "1NkM2QzZGMjEyMTIxCg", "/socket.io"); + + Assert.Equal(expectedPath, transport.Uri.AbsolutePath); + } + [Theory(DisplayName = "Required constructor arguments are rejected when missing")] [InlineData(null, "1NkM2QzZGMjEyMTIxCg")] [InlineData("", "1NkM2QzZGMjEyMTIxCg")] From 1a4334009b7778c433a193e3e08b2015054218ef Mon Sep 17 00:00:00 2001 From: redouane Date: Tue, 8 Sep 2026 20:04:21 +0200 Subject: [PATCH 10/21] feat(engine): report whether a connection is up, and why one failed Connected read _transport.Connected, and _transport does not exist until ConnectAsync has run, so asking before connecting threw a NullReferenceException. That pushed callers into keeping a shadow flag of their own, which then had to be maintained by hand. ConnectAsync reports a failure by completing the packet stream with its reason rather than by throwing, which suits a listener but leaves a caller that only ever sends with no way to see it. Record the reason so a protocol layered on top can decide whether it may send. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CsxPBM9F82FBXVQDV1jFmN --- src/EngineIO.Client/Engine.cs | 18 +++++++++++++++++- tests/EngineIO.Client.Tests/EngineTests.cs | 21 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/EngineIO.Client/Engine.cs b/src/EngineIO.Client/Engine.cs index b3598c5..037e78a 100644 --- a/src/EngineIO.Client/Engine.cs +++ b/src/EngineIO.Client/Engine.cs @@ -82,7 +82,22 @@ internal Engine(Action configure, HttpClient httpClient, _webSocket = webSocket; } - public bool Connected => _transport.Connected; + /// + /// Whether a transport is currently connected. False before + /// has succeeded, and false again once the + /// connection has gone away. + /// + public bool Connected => _transport?.Connected ?? false; + + /// + /// Why the last connection attempt failed, or null if none has. + /// + /// + /// reports failure through the packet stream rather + /// than by throwing, so a caller that does not listen — a protocol layered on + /// top, deciding whether it may send — has no other way to see the reason. + /// + public Exception? ConnectionError { get; private set; } /// /// Name of the transport currently in use, so tests can tell whether the @@ -261,6 +276,7 @@ private void ResetHeartbeat() private void HandleException(Exception exception) { _logger?.LogError(exception, exception.Message); + ConnectionError = exception; // End the stream with the reason it ended. A listener can then tell a // connection that died from one the server closed by agreement, which diff --git a/tests/EngineIO.Client.Tests/EngineTests.cs b/tests/EngineIO.Client.Tests/EngineTests.cs index 78cddb4..25bd7d8 100644 --- a/tests/EngineIO.Client.Tests/EngineTests.cs +++ b/tests/EngineIO.Client.Tests/EngineTests.cs @@ -192,6 +192,27 @@ async Task Should_Send_Over_The_Websocket_After_Upgrading() await engine.DisconnectAsync(); } + [Fact] + void Connected_Should_Be_False_Before_Connecting() + { + var (engine, _) = CreateEngine(Handshake()); + + Assert.False(engine.Connected); + Assert.Null(engine.ConnectionError); + } + + [Fact(DisplayName = "A handshake that fails is reported rather than thrown")] + async Task ConnectionError_Should_Report_Why_The_Handshake_Failed() + { + var (engine, _) = CreateEngine(Packet("4Hello")); + + await engine.ConnectAsync(); + + Assert.False(engine.Connected); + var exception = Assert.IsType(engine.ConnectionError); + Assert.Equal(ErrorReason.InvalidPacket, exception.ErrorReason); + } + private static (Engine Engine, List Requests) CreateEngine( FakeWebSocket socket, params byte[][] responses) { From 8da220cdca6152bae32ec6f6f5e21e72dc15cc56 Mon Sep 17 00:00:00 2001 From: redouane Date: Tue, 8 Sep 2026 20:04:48 +0200 Subject: [PATCH 11/21] fix(engine): keep the connection when the websocket upgrade fails _transport was repointed at the WebSocket transport before the probe had been answered, and a failed upgrade then tore the whole session down. Both are wrong: the upgrade is an optimisation over a polling connection that is already working, and the protocol says to carry on with long-polling when it does not succeed. Build the transport into a local, hand it the connection only once it has taken over, and dispose it otherwise. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CsxPBM9F82FBXVQDV1jFmN --- src/EngineIO.Client/Engine.cs | 14 ++++++++++---- tests/EngineIO.Client.Tests/EngineTests.cs | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/EngineIO.Client/Engine.cs b/src/EngineIO.Client/Engine.cs index 037e78a..0370741 100644 --- a/src/EngineIO.Client/Engine.cs +++ b/src/EngineIO.Client/Engine.cs @@ -172,18 +172,24 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) if (_clientOptions.AutoUpgrade && _httpTransport.Upgrades!.Contains("websocket")) { + // The upgrade is an optimisation, not a requirement: a probe the server + // never answers leaves the polling transport connected and in charge, so + // it is repointed only once the WebSocket has taken over. + WebSocketTransport? wsTransport = null; try { - _transport = _wsTransport = _webSocket is null + wsTransport = _webSocket is null ? new WebSocketTransport(_clientOptions.BaseAddress, _httpTransport.Sid!, _clientOptions.Path) : new WebSocketTransport(_webSocket, _clientOptions.BaseAddress, _httpTransport.Sid!, _clientOptions.Path); - await _wsTransport.ConnectAsync(cancellationToken).ConfigureAwait(false); + await wsTransport.ConnectAsync(cancellationToken).ConfigureAwait(false); + + _transport = _wsTransport = wsTransport; } catch (Exception exception) { - HandleException(exception); - return; + _logger?.LogWarning(exception, "Upgrade to websocket failed; staying on HTTP long-polling."); + wsTransport?.Dispose(); } } diff --git a/tests/EngineIO.Client.Tests/EngineTests.cs b/tests/EngineIO.Client.Tests/EngineTests.cs index 25bd7d8..d0c9437 100644 --- a/tests/EngineIO.Client.Tests/EngineTests.cs +++ b/tests/EngineIO.Client.Tests/EngineTests.cs @@ -213,6 +213,26 @@ async Task ConnectionError_Should_Report_Why_The_Handshake_Failed() Assert.Equal(ErrorReason.InvalidPacket, exception.ErrorReason); } + [Fact(DisplayName = "An upgrade that fails leaves the polling transport in charge")] + async Task Should_Stay_On_Polling_When_The_Upgrade_Fails() + { + // A pong without the "probe" payload is an unrelated pong, so the upgrade is + // never acknowledged. + var socket = new FakeWebSocket(); + socket.QueueText("3"); + var (engine, _) = CreateEngine(socket, Handshake(), Packet("4Hello"), Packet("1")); + + await engine.ConnectAsync(); + + Assert.True(engine.Connected); + Assert.Equal("polling", engine.TransportName); + Assert.Null(engine.ConnectionError); + + // The upgrade is an optimisation; losing it must not cost the connection. + var received = await Drain(engine); + Assert.Equal("Hello", Encoding.UTF8.GetString(Assert.Single(received).Body.Span)); + } + private static (Engine Engine, List Requests) CreateEngine( FakeWebSocket socket, params byte[][] responses) { From 6c46bafef525080a063e0ffcd19acc814e4705fe Mon Sep 17 00:00:00 2001 From: redouane Date: Tue, 8 Sep 2026 20:05:25 +0200 Subject: [PATCH 12/21] fix(engine): let a connection be opened again after one ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The packet channel and the polling cancellation source are both one-shot, and whatever ends a connection — a handshake that failed, a close the server sent, a heartbeat that ran out — completes and cancels them. A second ConnectAsync therefore handshook successfully and handed back a connection whose receive loop exited on its first check, so it could send but never receive. Replace both when connecting on top of a connection that has ended. Full reconnection — backoff, rejoining, resuming a session — is still to come; this only stops a retry being quietly broken. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CsxPBM9F82FBXVQDV1jFmN --- src/EngineIO.Client/Engine.cs | 36 ++++++++++++++++++++-- tests/EngineIO.Client.Tests/EngineTests.cs | 34 ++++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/EngineIO.Client/Engine.cs b/src/EngineIO.Client/Engine.cs index 0370741..fa90804 100644 --- a/src/EngineIO.Client/Engine.cs +++ b/src/EngineIO.Client/Engine.cs @@ -27,8 +27,14 @@ public sealed class Engine : IDisposable, IAsyncDisposable private readonly HttpClient? _httpClient; private readonly IWebSocket? _webSocket; private readonly ILogger? _logger; - private readonly Channel _packetsChannel = Channel.CreateUnbounded(); - private readonly CancellationTokenSource _pollingCancellationTokenSource = new(); + + /// + /// Replaced on a retry: a failed attempt completes the stream and cancels + /// polling, so reusing either would end the next connection before it began. + /// + private Channel _packetsChannel = Channel.CreateUnbounded(); + + private CancellationTokenSource _pollingCancellationTokenSource = new(); /// /// How long the connection may go without a server ping before it is @@ -157,6 +163,8 @@ private void DisposeCore() public async Task ConnectAsync(CancellationToken cancellationToken = default) { + ResetPreviousConnection(); + _transport = _httpTransport = _httpClient is null ? new HttpPollingTransport(_clientOptions.BaseAddress, _clientOptions.Path) : new HttpPollingTransport(_httpClient, _clientOptions.Path); @@ -279,6 +287,30 @@ private void ResetHeartbeat() _heartbeatCts?.CancelAfter(_heartbeatTimeoutMs); } + /// + /// Give a reconnection the clean state it needs. + /// + /// + /// Whatever ended the last connection — a handshake that failed, a close the + /// server sent, a heartbeat that ran out — cancelled polling and completed the + /// packet stream. Both are one-shot, so a second + /// would otherwise hand back a connection whose receive loop exits immediately. + /// + private void ResetPreviousConnection() + { + // Nothing to reset before the first attempt, and nothing to reset while a + // connection is still up. + if (_transport is null || Connected) + { + return; + } + + ConnectionError = null; + _pollingCancellationTokenSource.Dispose(); + _pollingCancellationTokenSource = new CancellationTokenSource(); + _packetsChannel = Channel.CreateUnbounded(); + } + private void HandleException(Exception exception) { _logger?.LogError(exception, exception.Message); diff --git a/tests/EngineIO.Client.Tests/EngineTests.cs b/tests/EngineIO.Client.Tests/EngineTests.cs index d0c9437..6570c05 100644 --- a/tests/EngineIO.Client.Tests/EngineTests.cs +++ b/tests/EngineIO.Client.Tests/EngineTests.cs @@ -213,6 +213,40 @@ async Task ConnectionError_Should_Report_Why_The_Handshake_Failed() Assert.Equal(ErrorReason.InvalidPacket, exception.ErrorReason); } + [Fact(DisplayName = "A failed attempt does not poison the connection that follows")] + async Task Should_Connect_After_A_Failed_Attempt() + { + var (engine, _) = CreateEngine(Packet("4Hello"), Handshake(), Packet("4Hi"), Packet("1")); + + await engine.ConnectAsync(); + Assert.False(engine.Connected); + + await engine.ConnectAsync(); + + Assert.True(engine.Connected); + Assert.Null(engine.ConnectionError); + + // The first attempt completed the packet stream with its reason; a retry that + // reused it would hand back a connection that never delivers anything. + var received = await Drain(engine); + Assert.Equal("Hi", Encoding.UTF8.GetString(Assert.Single(received).Body.Span)); + } + + [Fact(DisplayName = "A connection the server closed can be opened again")] + async Task Should_Connect_Again_After_The_Server_Closed_The_Connection() + { + var (engine, _) = CreateEngine(Handshake(), Packet("1"), Handshake(), Packet("4Hi"), Packet("1")); + + await engine.ConnectAsync(); + await Drain(engine); + Assert.False(engine.Connected); + + await engine.ConnectAsync(); + + Assert.True(engine.Connected); + Assert.Equal("Hi", Encoding.UTF8.GetString(Assert.Single(await Drain(engine)).Body.Span)); + } + [Fact(DisplayName = "An upgrade that fails leaves the polling transport in charge")] async Task Should_Stay_On_Polling_When_The_Upgrade_Fails() { From ea60441d83ce043cd1767af1dc9dd56e22262934 Mon Sep 17 00:00:00 2001 From: redouane Date: Tue, 8 Sep 2026 20:06:10 +0200 Subject: [PATCH 13/21] test(socketio): follow the Engine.IO test naming convention The Socket.IO tests were written in camelCase with a public modifier, while every Engine.IO test is a Pascal_Snake_Case method with no access modifier. Two conventions in one solution make a test list harder to read than either would on its own. Renames only: no test was added, removed or changed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CsxPBM9F82FBXVQDV1jFmN --- .../Packets/BinaryEventPacketTests.cs | 24 ++++++------- .../Packets/ConnectPacketTests.cs | 12 +++---- .../Packets/DisconnectPacketTests.cs | 10 +++--- .../Packets/EventPacketTests.cs | 34 +++++++++---------- 4 files changed, 40 insertions(+), 40 deletions(-) diff --git a/tests/SocketIO.Client.Tests/Packets/BinaryEventPacketTests.cs b/tests/SocketIO.Client.Tests/Packets/BinaryEventPacketTests.cs index 71e8215..a11728b 100644 --- a/tests/SocketIO.Client.Tests/Packets/BinaryEventPacketTests.cs +++ b/tests/SocketIO.Client.Tests/Packets/BinaryEventPacketTests.cs @@ -7,7 +7,7 @@ namespace SocketIO.Client.Tests.Packets; public class BinaryEventPacketTests { [Fact] - void ShouldCreateBinaryEventPacket() + void Should_Create_Binary_Event_Packet() { var packet = new Packet(PacketType.BinaryEvent); packet.AddItem(new ReadOnlyMemory([1, 2, 3])); @@ -17,7 +17,7 @@ void ShouldCreateBinaryEventPacket() } [Fact] - public void ShouldCreateBinaryPacketWithNamespace() + void Should_Create_Binary_Packet_With_Namespace() { var @namespace = "test"; @@ -27,7 +27,7 @@ public void ShouldCreateBinaryPacketWithNamespace() } [Fact] - public void ShouldCreateBinaryPacketWithEventName() + void Should_Create_Binary_Packet_With_Event_Name() { var eventName = "test"; @@ -37,7 +37,7 @@ public void ShouldCreateBinaryPacketWithEventName() } [Fact] - public void ShouldCreateBinaryPacketWithAckId() + void Should_Create_Binary_Packet_With_Ack_Id() { var ackId = 42; @@ -47,7 +47,7 @@ public void ShouldCreateBinaryPacketWithAckId() } [Fact] - public void ShouldSerializeBinaryEventPacket() + void Should_Serialize_Binary_Event_Packet() { var packet = new Packet(PacketType.BinaryEvent); packet.AddItem(new ReadOnlyMemory([1, 2, 3])); @@ -58,7 +58,7 @@ public void ShouldSerializeBinaryEventPacket() } [Fact] - public void ShouldSerializeBinaryPacketWithNamespace() + void Should_Serialize_Binary_Packet_With_Namespace() { var @namespace = "test"; var expectedEncodedPacket = $$"""51-/{{@namespace}},["message",{"_placeholder":true,"num":0}]"""; @@ -70,7 +70,7 @@ public void ShouldSerializeBinaryPacketWithNamespace() } [Fact] - public void ShouldSerializeBinaryPacketWithEventName() + void Should_Serialize_Binary_Packet_With_Event_Name() { var eventName = "test"; var expectedEncodedPacket = $$"""51-["{{eventName}}",{"_placeholder":true,"num":0}]"""; @@ -82,7 +82,7 @@ public void ShouldSerializeBinaryPacketWithEventName() } [Fact] - public void ShouldSerializeBinaryPacketWithAckId() + void Should_Serialize_Binary_Packet_With_Ack_Id() { var ackId = 42; @@ -96,7 +96,7 @@ public void ShouldSerializeBinaryPacketWithAckId() } [Fact(DisplayName = "Attachments are numbered in the order they were added")] - public void ShouldNumberAttachmentsInOrder() + void Should_Number_Attachments_In_Order() { var first = new ReadOnlyMemory([1, 2, 3]); var second = new ReadOnlyMemory([4, 5, 6]); @@ -112,7 +112,7 @@ public void ShouldNumberAttachmentsInOrder() } [Fact(DisplayName = "Binary and text arguments can be mixed in one payload")] - public void ShouldMixTextAndBinaryArguments() + void Should_Mix_Text_And_Binary_Arguments() { var expectedEncodedPacket = """51-["message","Hello!",{"_placeholder":true,"num":0}]"""; @@ -124,7 +124,7 @@ public void ShouldMixTextAndBinaryArguments() } [Fact(DisplayName = "A byte array is treated as binary, not as a Json value")] - public void ShouldTreatAByteArrayAsBinary() + void Should_Treat_A_Byte_Array_As_Binary() { byte[] attachment = [1, 2, 3]; @@ -137,7 +137,7 @@ public void ShouldTreatAByteArrayAsBinary() } [Fact(DisplayName = "The attachments are kept out of the encoded header")] - public void ShouldKeepAttachmentsOutOfTheHeader() + void Should_Keep_Attachments_Out_Of_The_Header() { var attachment = new ReadOnlyMemory([1, 2, 3]); diff --git a/tests/SocketIO.Client.Tests/Packets/ConnectPacketTests.cs b/tests/SocketIO.Client.Tests/Packets/ConnectPacketTests.cs index 9eb39b2..73c9afc 100644 --- a/tests/SocketIO.Client.Tests/Packets/ConnectPacketTests.cs +++ b/tests/SocketIO.Client.Tests/Packets/ConnectPacketTests.cs @@ -7,7 +7,7 @@ namespace SocketIO.Client.Tests.Packets; public class ConnectPacketTests { [Fact] - void ShouldCreateConnectPacket() + void Should_Create_Connect_Packet() { var packet = Packet.ConnectPacket; @@ -16,7 +16,7 @@ void ShouldCreateConnectPacket() } [Fact] - void ShouldCreateConnectPacketWithNamespace() + void Should_Create_Connect_Packet_With_Namespace() { var @namespace = "test"; @@ -27,7 +27,7 @@ void ShouldCreateConnectPacketWithNamespace() } [Fact] - void ShouldSerializeConnectPacket() + void Should_Serialize_Connect_Packet() { var connectPacket = Packet.ConnectPacket; @@ -38,7 +38,7 @@ void ShouldSerializeConnectPacket() } [Fact] - void ShouldSerializeConnectPacketWithNamespace() + void Should_Serialize_Connect_Packet_With_Namespace() { var @namespace = "test"; var connectPacket = new Packet(PacketType.Connect, @namespace); @@ -50,7 +50,7 @@ void ShouldSerializeConnectPacketWithNamespace() } [Fact] - void ShouldThrowExceptionWhenAddingItemToConnectPacket() + void Should_Reject_A_Payload_On_A_Connect_Packet() { var packet = Packet.ConnectPacket; @@ -58,7 +58,7 @@ void ShouldThrowExceptionWhenAddingItemToConnectPacket() } [Fact(DisplayName = "The shared Connect packet can be serialized more than once")] - void ShouldSerializeSharedConnectPacketRepeatedly() + void Should_Serialize_The_Shared_Connect_Packet_Repeatedly() { var first = Encoding.UTF8.GetString(Packet.ConnectPacket.Serialize().Span); var second = Encoding.UTF8.GetString(Packet.ConnectPacket.Serialize().Span); diff --git a/tests/SocketIO.Client.Tests/Packets/DisconnectPacketTests.cs b/tests/SocketIO.Client.Tests/Packets/DisconnectPacketTests.cs index dd93a0f..e259f37 100644 --- a/tests/SocketIO.Client.Tests/Packets/DisconnectPacketTests.cs +++ b/tests/SocketIO.Client.Tests/Packets/DisconnectPacketTests.cs @@ -7,7 +7,7 @@ namespace SocketIO.Client.Tests.Packets; public class DisconnectPacketTests { [Fact] - void ShouldCreateDisconnectPacket() + void Should_Create_Disconnect_Packet() { var packet = Packet.DisconnectPacket; @@ -16,7 +16,7 @@ void ShouldCreateDisconnectPacket() } [Fact] - void ShouldCreateDisconnectPacketWithNamespace() + void Should_Create_Disconnect_Packet_With_Namespace() { var @namespace = "test"; @@ -27,7 +27,7 @@ void ShouldCreateDisconnectPacketWithNamespace() } [Fact] - void ShouldSerializeDisconnectPacket() + void Should_Serialize_Disconnect_Packet() { var packet = Packet.DisconnectPacket; @@ -38,7 +38,7 @@ void ShouldSerializeDisconnectPacket() } [Fact] - void ShouldSerializeDisconnectPacketWithNamespace() + void Should_Serialize_Disconnect_Packet_With_Namespace() { var @namespace = "test"; var packet = new Packet(PacketType.Disconnect, @namespace); @@ -50,7 +50,7 @@ void ShouldSerializeDisconnectPacketWithNamespace() } [Fact] - void ShouldThrowExceptionWhenAddingItemToDisconnectPacket() + void Should_Reject_A_Payload_On_A_Disconnect_Packet() { var packet = Packet.DisconnectPacket; diff --git a/tests/SocketIO.Client.Tests/Packets/EventPacketTests.cs b/tests/SocketIO.Client.Tests/Packets/EventPacketTests.cs index 3e39cca..0819675 100644 --- a/tests/SocketIO.Client.Tests/Packets/EventPacketTests.cs +++ b/tests/SocketIO.Client.Tests/Packets/EventPacketTests.cs @@ -13,7 +13,7 @@ class Foo public class EventPacketTests { [Fact] - void ShouldCreateEventPacket() + void Should_Create_Event_Packet() { var packet = new Packet(PacketType.Event); @@ -23,7 +23,7 @@ void ShouldCreateEventPacket() } [Fact] - void ShouldCreateEventPacketWithNamespace() + void Should_Create_Event_Packet_With_Namespace() { var @namespace = "test"; @@ -37,7 +37,7 @@ void ShouldCreateEventPacketWithNamespace() [Theory(DisplayName = "A namespace encodes the same however it was spelled")] [InlineData("test")] [InlineData("/test")] - void ShouldNormalizeNamespace(string @namespace) + void Should_Normalize_Namespace(string @namespace) { var packet = new Packet(PacketType.Event, @namespace); packet.AddItem("Hello!"); @@ -47,7 +47,7 @@ void ShouldNormalizeNamespace(string @namespace) } [Fact] - void ShouldCreateEventPacketWithEventName() + void Should_Create_Event_Packet_With_Event_Name() { var eventName = "test"; @@ -59,7 +59,7 @@ void ShouldCreateEventPacketWithEventName() } [Fact] - void ShouldCreateEventPacketWithAckId() + void Should_Create_Ack_Packet_With_Ack_Id() { var ackId = 42; @@ -70,7 +70,7 @@ void ShouldCreateEventPacketWithAckId() } [Fact(DisplayName = "An event can request an acknowledgement")] - void ShouldCreateEventPacketRequestingAnAcknowledgement() + void Should_Request_An_Acknowledgement_On_An_Event() { var ackId = 7; @@ -82,13 +82,13 @@ void ShouldCreateEventPacketRequestingAnAcknowledgement() } [Fact(DisplayName = "An acknowledgement does not carry an event name")] - void ShouldRejectAnEventNameOnAnAcknowledgement() + void Should_Reject_An_Event_Name_On_An_Acknowledgement() { Assert.Throws(() => new Packet(PacketType.Ack, null, "test")); } [Fact] - void ShouldSerializePlainTextEventPacket() + void Should_Serialize_Plaintext_Event_Packet() { var packet = new Packet(PacketType.Event); packet.AddItem("Hello!"); @@ -99,7 +99,7 @@ void ShouldSerializePlainTextEventPacket() } [Fact] - void ShouldThrowWhenAddingInvalidPayloadPacket() + void Should_Reject_Binary_On_A_Plaintext_Event() { var packet = new Packet(PacketType.Event); var invalidPayload = new ReadOnlyMemory(new byte[] { 1, 2, 3 }); @@ -108,7 +108,7 @@ void ShouldThrowWhenAddingInvalidPayloadPacket() } [Fact] - void ShouldSerializePlainTextEventWithNamespace() + void Should_Serialize_Plaintext_Event_With_Namespace() { var @namespace = "test"; var expectedEncodedPacket = $"""2/{@namespace},["message","Hello!"]"""; @@ -120,7 +120,7 @@ void ShouldSerializePlainTextEventWithNamespace() } [Fact] - void ShouldSerializePlainTextEventWithEventName() + void Should_Serialize_Plaintext_Event_With_Event_Name() { var eventName = "test"; var expectedEncodedPacket = $$"""2["{{eventName}}","Hello!"]"""; @@ -132,7 +132,7 @@ void ShouldSerializePlainTextEventWithEventName() } [Fact] - void ShouldSerializePlainTextWithAckIdPacket() + void Should_Serialize_Plaintext_Ack_Packet() { var ackId = 42; @@ -147,7 +147,7 @@ void ShouldSerializePlainTextWithAckIdPacket() } [Fact] - void ShouldSerializeJsonEventPacket() + void Should_Serialize_Json_Event_Packet() { var packet = new Packet(PacketType.Event); packet.AddItem(new Foo { Value = "bar" }); @@ -158,7 +158,7 @@ void ShouldSerializeJsonEventPacket() } [Fact] - void ShouldSerializeJsonEventPacketWithNamespace() + void Should_Serialize_Json_Event_With_Namespace() { var @namespace = "test"; var expectedEncodedPacket = $$"""2/{{@namespace}},["message",{"Value":"bar"}]"""; @@ -170,7 +170,7 @@ void ShouldSerializeJsonEventPacketWithNamespace() } [Fact] - void ShouldSerializeJsonEventWithEventName() + void Should_Serialize_Json_Event_With_Event_Name() { var eventName = "test"; var expectedEncodedPacket = $$"""2["{{eventName}}",{"Value":"bar"}]"""; @@ -182,7 +182,7 @@ void ShouldSerializeJsonEventWithEventName() } [Fact] - void ShouldSerializeJsonEventWithAckIdPacket() + void Should_Serialize_Json_Ack_Packet() { var ackId = 42; var expectedEncodedPacket = $$"""3{{ackId}}[{"Value":"bar"}]"""; @@ -194,7 +194,7 @@ void ShouldSerializeJsonEventWithAckIdPacket() } [Fact(DisplayName = "The same packet encodes identically every time it is sent")] - void ShouldSerializeTheSamePacketRepeatedly() + void Should_Serialize_The_Same_Packet_Repeatedly() { var packet = new Packet(PacketType.Event); packet.AddItem("Hello!"); From 537263a3626d28bd1b8ddc410f006d8ba037e910 Mon Sep 17 00:00:00 2001 From: redouane Date: Tue, 8 Sep 2026 20:06:32 +0200 Subject: [PATCH 14/21] fix(socketio): reject the packets a decoder refuses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five shapes encoded cleanly here and were then thrown out by the server. A decode error is not a dropped message: socket.io answers one by closing the connection, so each of these cost the whole session. - A binary packet announces its attachment count, and the decoder refuses a count below one. Both the marker and the requirement were missing: a BinaryEvent carrying only text encoded as `5["a","b"]` ("Illegal attachments"). - A negative ack id encoded as `2-1[...]`; the parser reads digits only, so the sign began the payload. - A namespace holding a comma split the header, the comma being what ends the namespace: `2/a,b,["message"]`. - An Ack with no id encoded as `3[...]`, which the server looks up among the callbacks it is waiting on and discards. - An event named after a reserved one — connect, disconnect and the rest — was refused outright as an invalid payload. Fold the four constructors onto one private constructor so that every way in is validated, and refuse an attachment-less binary packet when it is serialized, since its attachments arrive after it is built. Checked each encoding against socket.io-parser, the reference decoder. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CsxPBM9F82FBXVQDV1jFmN --- src/SocketIO.Client/Packets/Packet.cs | 90 ++++++++++++++++--- .../Packets/BinaryEventPacketTests.cs | 13 +++ .../Packets/EventPacketTests.cs | 51 ++++++++++- 3 files changed, 142 insertions(+), 12 deletions(-) diff --git a/src/SocketIO.Client/Packets/Packet.cs b/src/SocketIO.Client/Packets/Packet.cs index 102197e..fced04c 100644 --- a/src/SocketIO.Client/Packets/Packet.cs +++ b/src/SocketIO.Client/Packets/Packet.cs @@ -40,16 +40,30 @@ public sealed class Packet private readonly List> _attachments = new(); public Packet(PacketType type) - : this(type, null, null) + : this(type, null, null, null) { } public Packet(PacketType type, string? @namespace) - : this(type, @namespace, null) + : this(type, @namespace, null, null) { } public Packet(PacketType type, string? @namespace, string? @event) + : this(type, @namespace, @event, null) + { + } + + public Packet(PacketType type, int ackId, string? @namespace, string? @event) + : this(type, @namespace, @event, ackId) + { + } + + /// + /// The one constructor that validates, so that no combination reaches the + /// wire without having been checked. + /// + private Packet(PacketType type, string? @namespace, string? @event, int? ackId) { if (!Enum.IsDefined(type)) { @@ -61,8 +75,29 @@ public Packet(PacketType type, string? @namespace, string? @event) throw new ArgumentException($"A {type} packet does not carry an event name.", nameof(@event)); } + if (ackId.HasValue && !CarriesAckId(type)) + { + throw new ArgumentException($"A {type} packet cannot carry an acknowledgement id.", nameof(type)); + } + + if (ackId is < 0) + { + throw new ArgumentOutOfRangeException(nameof(ackId), ackId, + "An acknowledgement id is a non-negative number; a sign would be read as the start of the payload."); + } + + // An acknowledgement that names no id answers nothing: the server looks the id + // up among the callbacks it is waiting on and discards the packet when it is + // missing, so it is refused here rather than sent into the void. + if (!ackId.HasValue && type is PacketType.Ack or PacketType.BinaryAck) + { + throw new ArgumentException($"A {type} packet has to name the acknowledgement it answers.", + nameof(ackId)); + } + Type = type; Namespace = NormalizeNamespace(@namespace); + AckId = ackId; if (!CarriesEventName(type)) { @@ -72,18 +107,15 @@ public Packet(PacketType type, string? @namespace, string? @event) // The event name is not header material: it is the first argument of the // payload array, which is why it is seeded as an item like any other. Event = @event ?? DefaultEventName; - _data.Add(new TextPacketData(Event)); - } - public Packet(PacketType type, int ackId, string? @namespace, string? @event) - : this(type, @namespace, @event) - { - if (!CarriesAckId(type)) + if (IsReservedEventName(Event)) { - throw new ArgumentException($"A {type} packet cannot carry an acknowledgement id.", nameof(type)); + throw new ArgumentException( + $"\"{Event}\" is reserved by the protocol; a packet naming it is rejected by the server.", + nameof(@event)); } - AckId = ackId; + _data.Add(new TextPacketData(Event)); } /// @@ -208,6 +240,17 @@ internal ReadOnlyMemory Serialize() /// internal void Serialize(IBufferWriter writer) { + // A decoder reads the announced count and refuses anything below one, so a + // binary packet with nothing attached is not an empty packet — it is one the + // server drops the connection over. It is caught here rather than in the + // constructor because the attachments arrive after it. + if ((Type is PacketType.BinaryEvent or PacketType.BinaryAck) && _attachments.Count == 0) + { + throw new InvalidOperationException( + $"A {Type} packet has to carry at least one binary argument; " + + $"use {nameof(PacketType.Event)} or {nameof(PacketType.Ack)} for a payload that has none."); + } + WriteHeader(writer); WritePayload(writer); } @@ -216,7 +259,10 @@ private void WriteHeader(IBufferWriter writer) { WriteByte(writer, (byte)Type); - if (_attachments.Count > 0) + // The count and its dash are what mark a type 5 or 6 packet as binary, so they + // are written for the type rather than for the attachments happening to be + // there — Serialize has already refused the packet if they are not. + if (Type is PacketType.BinaryEvent or PacketType.BinaryAck) { WriteInt32(writer, _attachments.Count); WriteByte(writer, (byte)'-'); @@ -297,9 +343,31 @@ private static string NormalizeNamespace(string? @namespace) } var trimmed = @namespace!.Trim(); + + // The comma is what ends the namespace in the header, so one inside it would + // truncate the name and leave the remainder to be parsed as the payload. + if (trimmed.Contains(',')) + { + throw new ArgumentException("A namespace cannot contain a comma; it is the header's separator.", + nameof(@namespace)); + } + return trimmed.StartsWith('/') ? trimmed : "/" + trimmed; } + /// + /// Whether an event name is one the protocol keeps for itself. + /// + /// + /// A server rejects a packet whose first argument is one of these, and a + /// rejected packet costs the whole connection rather than just the message. + /// + private static bool IsReservedEventName(string @event) + { + return @event is "connect" or "connect_error" or "disconnect" or "disconnecting" + or "newListener" or "removeListener"; + } + private static bool CarriesEventName(PacketType type) { return type is PacketType.Event or PacketType.BinaryEvent; diff --git a/tests/SocketIO.Client.Tests/Packets/BinaryEventPacketTests.cs b/tests/SocketIO.Client.Tests/Packets/BinaryEventPacketTests.cs index a11728b..2f35404 100644 --- a/tests/SocketIO.Client.Tests/Packets/BinaryEventPacketTests.cs +++ b/tests/SocketIO.Client.Tests/Packets/BinaryEventPacketTests.cs @@ -95,6 +95,19 @@ void Should_Serialize_Binary_Packet_With_Ack_Id() Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); } + [Theory(DisplayName = "A decoder refuses a binary packet that announces nothing to attach")] + [InlineData(PacketType.BinaryEvent)] + [InlineData(PacketType.BinaryAck)] + void Should_Reject_A_Binary_Packet_Without_An_Attachment(PacketType type) + { + var packet = type == PacketType.BinaryAck + ? new Packet(type, 1, null, null) + : new Packet(type); + packet.AddItem("Hello!"); + + Assert.Throws(() => packet.Serialize()); + } + [Fact(DisplayName = "Attachments are numbered in the order they were added")] void Should_Number_Attachments_In_Order() { diff --git a/tests/SocketIO.Client.Tests/Packets/EventPacketTests.cs b/tests/SocketIO.Client.Tests/Packets/EventPacketTests.cs index 0819675..2068e2a 100644 --- a/tests/SocketIO.Client.Tests/Packets/EventPacketTests.cs +++ b/tests/SocketIO.Client.Tests/Packets/EventPacketTests.cs @@ -46,6 +46,12 @@ void Should_Normalize_Namespace(string @namespace) Assert.Equal("""2/test,["message","Hello!"]""", Encoding.UTF8.GetString(packet.Serialize().Span)); } + [Fact(DisplayName = "A comma would end the namespace early, so one is refused")] + void Should_Reject_A_Namespace_Containing_A_Comma() + { + Assert.Throws(() => new Packet(PacketType.Event, "a,b")); + } + [Fact] void Should_Create_Event_Packet_With_Event_Name() { @@ -58,6 +64,18 @@ void Should_Create_Event_Packet_With_Event_Name() Assert.Equal(eventName, packet.Event); } + [Theory(DisplayName = "An event name the protocol keeps for itself is refused")] + [InlineData("connect")] + [InlineData("connect_error")] + [InlineData("disconnect")] + [InlineData("disconnecting")] + [InlineData("newListener")] + [InlineData("removeListener")] + void Should_Reject_A_Reserved_Event_Name(string eventName) + { + Assert.Throws(() => new Packet(PacketType.Event, null, eventName)); + } + [Fact] void Should_Create_Ack_Packet_With_Ack_Id() { @@ -84,7 +102,38 @@ void Should_Request_An_Acknowledgement_On_An_Event() [Fact(DisplayName = "An acknowledgement does not carry an event name")] void Should_Reject_An_Event_Name_On_An_Acknowledgement() { - Assert.Throws(() => new Packet(PacketType.Ack, null, "test")); + Assert.Throws(() => new Packet(PacketType.Ack, 1, null, "test")); + } + + [Theory(DisplayName = "An acknowledgement has to name the event it answers")] + [InlineData(PacketType.Ack)] + [InlineData(PacketType.BinaryAck)] + void Should_Reject_An_Acknowledgement_Without_An_Ack_Id(PacketType type) + { + Assert.Throws(() => new Packet(type, null, null)); + } + + [Theory(DisplayName = "A sign would be read as the start of the payload")] + [InlineData(-1)] + [InlineData(int.MinValue)] + void Should_Reject_A_Negative_Ack_Id(int ackId) + { + Assert.Throws(() => new Packet(PacketType.Event, ackId, null, null)); + } + + [Theory(DisplayName = "Only the types that take part in one carry an ack id")] + [InlineData(PacketType.Connect)] + [InlineData(PacketType.Disconnect)] + [InlineData(PacketType.ConnectError)] + void Should_Reject_An_Ack_Id_On_A_Type_That_Cannot_Carry_One(PacketType type) + { + Assert.Throws(() => new Packet(type, 1, null, null)); + } + + [Fact] + void Should_Reject_An_Unknown_Packet_Type() + { + Assert.Throws(() => new Packet((PacketType)0x39)); } [Fact] From 69b6a36fd7d86fd9ddc56dc6712d65b65eab81e7 Mon Sep 17 00:00:00 2001 From: redouane Date: Tue, 8 Sep 2026 20:07:38 +0200 Subject: [PATCH 15/21] test(socketio): drive the client from a stubbed transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IO had no seam, so nothing about the client itself was under test: only the packet encoding was. Give it the internal HttpClient constructor that Engine and the transports already expose for their tests, and grant SocketIO.Client the InternalsVisibleTo it needs to reach Engine's. The handler is hand-rolled rather than mocked: the ordering tests need the requests as a sequence, and this project takes no Moq dependency. Pins what the client does today — the CONNECT packet, the namespace form, the path it asks for, and a binary event going out as a header followed by its attachment — before any of it changes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CsxPBM9F82FBXVQDV1jFmN --- src/EngineIO.Client/AssemblyInfo.cs | 5 +- src/SocketIO.Client/IO.cs | 22 +++- .../FakePollingServer.cs | 107 ++++++++++++++++++ tests/SocketIO.Client.Tests/IOTests.cs | 86 ++++++++++++++ 4 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 tests/SocketIO.Client.Tests/FakePollingServer.cs create mode 100644 tests/SocketIO.Client.Tests/IOTests.cs diff --git a/src/EngineIO.Client/AssemblyInfo.cs b/src/EngineIO.Client/AssemblyInfo.cs index 8ac9362..e2be62a 100644 --- a/src/EngineIO.Client/AssemblyInfo.cs +++ b/src/EngineIO.Client/AssemblyInfo.cs @@ -1,3 +1,6 @@ using System.Runtime.CompilerServices; -[assembly: InternalsVisibleTo("EngineIO.Client.Tests")] \ No newline at end of file +[assembly: InternalsVisibleTo("EngineIO.Client.Tests")] + +// The Socket.IO layer needs the same stubbed-transport seam its tests do. +[assembly: InternalsVisibleTo("SocketIO.Client")] \ No newline at end of file diff --git a/src/SocketIO.Client/IO.cs b/src/SocketIO.Client/IO.cs index 0ffb79d..e21207c 100644 --- a/src/SocketIO.Client/IO.cs +++ b/src/SocketIO.Client/IO.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -41,13 +42,30 @@ public IO(string baseAddress, string path = DefaultPath, ILoggerFactory? loggerF { Path = path; - _client = new Engine(options => + _client = new Engine(Configure(baseAddress, path), loggerFactory); + } + + /// + /// Drives the connection from a supplied , so the + /// protocol behaviour can be exercised against a stubbed server. + /// + internal IO(HttpClient httpClient, string baseAddress, string path = DefaultPath, + ILoggerFactory? loggerFactory = null) + { + Path = path; + + _client = new Engine(Configure(baseAddress, path), httpClient, loggerFactory: loggerFactory); + } + + private static Action Configure(string baseAddress, string path) + { + return options => { options.BaseAddress = baseAddress; options.Path = path; options.AutoUpgrade = true; // TODO: allow passing custom headers and queries - }, loggerFactory); + }; } /// diff --git a/tests/SocketIO.Client.Tests/FakePollingServer.cs b/tests/SocketIO.Client.Tests/FakePollingServer.cs new file mode 100644 index 0000000..0ae123b --- /dev/null +++ b/tests/SocketIO.Client.Tests/FakePollingServer.cs @@ -0,0 +1,107 @@ +using System.Net; +using System.Text; + +namespace SocketIO.Client.Tests; + +/// +/// A request the fake server received. +/// +public sealed record CapturedRequest(HttpMethod Method, Uri Uri, byte[] Body) +{ + public string Text => Encoding.UTF8.GetString(Body); +} + +/// +/// Stands in for an Engine.io server over HTTP long-polling: answers each GET with +/// the next scripted payload, answers every POST the way the protocol requires, and +/// records every request in the order it arrived. +/// +/// +/// Hand-rolled rather than mocked because the ordering tests need the requests as a +/// sequence, and because this project takes no test-double dependency. +/// +public sealed class FakePollingServer : HttpMessageHandler +{ + private readonly List _requests = new(); + private readonly byte[][] _pollResponses; + private int _polls; + + public FakePollingServer(params byte[][] pollResponses) + { + _pollResponses = pollResponses; + } + + /// + /// How long a GET takes to answer, the way a real long poll does. Keeps the + /// receive loop from spinning while a test is doing something else. + /// + public TimeSpan PollDelay { get; init; } = TimeSpan.FromMilliseconds(20); + + public IReadOnlyList Requests + { + get + { + lock (_requests) + { + return _requests.ToArray(); + } + } + } + + /// + /// The bodies the client posted, in order. + /// + public IReadOnlyList Posts + { + get { return Requests.Where(request => request.Method == HttpMethod.Post).Select(r => r.Text).ToArray(); } + } + + public static byte[] Handshake(string upgrades = "") + { + return Encoding.UTF8.GetBytes( + $$"""0{"sid":"1NkM2QzZGMjEyMTIxCg","maxPayload":1000000,"pingTimeout":20000,"pingInterval":25000,"upgrades":[{{upgrades}}]}"""); + } + + public static byte[] Payload(string payload) + { + return Encoding.UTF8.GetBytes(payload); + } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = request.Content is null + ? Array.Empty() + : await request.Content.ReadAsByteArrayAsync(cancellationToken); + + lock (_requests) + { + _requests.Add(new CapturedRequest(request.Method, request.RequestUri!, body)); + } + + if (request.Method != HttpMethod.Get) + { + return Ok("ok"u8.ToArray()); + } + + if (PollDelay > TimeSpan.Zero) + { + await Task.Delay(PollDelay, cancellationToken); + } + + if (_pollResponses.Length == 0) + { + return Ok(Array.Empty()); + } + + // The last scripted response is repeated once the script runs out, so a poll + // loop never starves. + var index = Math.Min(Interlocked.Increment(ref _polls) - 1, _pollResponses.Length - 1); + return Ok(_pollResponses[index]); + } + + private static HttpResponseMessage Ok(byte[] body) + { + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(body) }; + } +} \ No newline at end of file diff --git a/tests/SocketIO.Client.Tests/IOTests.cs b/tests/SocketIO.Client.Tests/IOTests.cs new file mode 100644 index 0000000..ce4013e --- /dev/null +++ b/tests/SocketIO.Client.Tests/IOTests.cs @@ -0,0 +1,86 @@ +namespace SocketIO.Client.Tests; + +public class IOTests +{ + [Fact] + async Task Should_Send_Connect_Packet_After_The_Handshake() + { + var (io, server) = CreateClient(FakePollingServer.Handshake()); + + await using (io) + { + await io.ConnectAsync(); + + Assert.Equal("40", Assert.Single(server.Posts)); + } + } + + [Fact] + async Task Should_Send_Connect_Packet_For_A_Namespace() + { + var (io, server) = CreateClient(FakePollingServer.Handshake()); + + await using (io) + { + await io.ConnectAsync("admin"); + + Assert.Equal("40/admin,", Assert.Single(server.Posts)); + } + } + + [Fact] + async Task Should_Serve_From_The_Socket_IO_Path() + { + var (io, server) = CreateClient(FakePollingServer.Handshake()); + + await using (io) + { + await io.ConnectAsync(); + + // The trailing slash is what a Socket.IO server matches on. + Assert.StartsWith("/socket.io/?EIO=4", server.Requests[0].Uri.PathAndQuery); + } + } + + [Fact] + async Task Should_Send_A_Text_Event() + { + var (io, server) = CreateClient(FakePollingServer.Handshake()); + + await using (io) + { + await io.ConnectAsync(); + await io.SendAsync("Hello!", "greeting"); + + Assert.Equal("""42["greeting","Hello!"]""", server.Posts[1]); + } + } + + [Fact(DisplayName = "A binary event is a header followed by its attachment")] + async Task Should_Send_A_Binary_Event_As_Header_Then_Attachment() + { + var (io, server) = CreateClient(FakePollingServer.Handshake()); + + await using (io) + { + await io.ConnectAsync(); + await io.SendAsync(new byte[] { 1, 2, 3 }); + + Assert.Equal("""451-["message",{"_placeholder":true,"num":0}]""", server.Posts[1]); + + // Long-polling carries text only, so the attachment travels base64-encoded + // behind a 'b' prefix. + Assert.Equal("b" + Convert.ToBase64String([1, 2, 3]), server.Posts[2]); + } + } + + private static (IO Client, FakePollingServer Server) CreateClient(params byte[][] pollResponses) + { + // No websocket among the advertised upgrades, so the client stays on polling + // and every packet it sends is a POST this server can be asked about. + var server = new FakePollingServer(pollResponses); + var httpClient = new HttpClient(server) { BaseAddress = new Uri("http://foo.bar") }; + + return (new IO(httpClient, "http://foo.bar"), server); + } +} \ No newline at end of file From 5605fb5f269b700923e8752e03f553833408a012 Mon Sep 17 00:00:00 2001 From: redouane Date: Tue, 8 Sep 2026 20:08:32 +0200 Subject: [PATCH 16/21] fix(socketio): stop reporting a failed handshake as a connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine.ConnectAsync completes normally whether or not the handshake worked, and IO took that as proof, setting a _connected flag it never cleared. Against a server that was down, the client latched: the caller saw an HttpRequestException from the CONNECT send rather than a connect failure, and every later ConnectAsync skipped the handshake, so it stayed unusable even once the server came back — a second attempt posted CONNECT with no sid and was answered 400. Drop the flag and ask the engine, which now answers before it has a transport and answers false again once the connection has gone. A handshake that did not come up is raised as IOConnectionException, carrying the engine's reason, and sending before connecting says so instead of dereferencing a transport that is not there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CsxPBM9F82FBXVQDV1jFmN --- .../Exceptions/IOConnectionException.cs | 24 +++++++++ src/SocketIO.Client/IO.cs | 49 ++++++++++++++++--- tests/SocketIO.Client.Tests/IOTests.cs | 47 ++++++++++++++++++ 3 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 src/SocketIO.Client/Exceptions/IOConnectionException.cs diff --git a/src/SocketIO.Client/Exceptions/IOConnectionException.cs b/src/SocketIO.Client/Exceptions/IOConnectionException.cs new file mode 100644 index 0000000..7c66f25 --- /dev/null +++ b/src/SocketIO.Client/Exceptions/IOConnectionException.cs @@ -0,0 +1,24 @@ +using System; + +namespace SocketIO.Client.Exceptions; + +/// +/// Thrown when an operation needs a connection that is not there. +/// +/// +/// Engine.io reports a failed handshake by completing its packet stream with the +/// reason rather than by throwing, so the reason is carried here as the inner +/// exception instead of being lost to a caller that only sends. +/// +public class IOConnectionException : Exception +{ + public IOConnectionException(string message) + : base(message) + { + } + + public IOConnectionException(string message, Exception? innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/SocketIO.Client/IO.cs b/src/SocketIO.Client/IO.cs index e21207c..813490f 100644 --- a/src/SocketIO.Client/IO.cs +++ b/src/SocketIO.Client/IO.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging; +using SocketIO.Client.Exceptions; using SocketIO.Client.Packets; using EnginePacket = EngineIO.Client.Packets.Packet; @@ -32,11 +33,10 @@ public sealed class IO : IAsyncDisposable private readonly Dictionary _namespaces = new(); /// - /// Whether the underlying Engine.io connection has been established, tracked - /// here because has no transport to answer for - /// until it has. + /// Serializes connection attempts, so that two callers joining a namespace at + /// once open one Engine.io connection between them rather than one each. /// - private bool _connected; + private readonly SemaphoreSlim _connectLock = new(1, 1); public IO(string baseAddress, string path = DefaultPath, ILoggerFactory? loggerFactory = null) { @@ -73,9 +73,15 @@ private static Action Configure(string baseAddress, string path) /// public string Path { get; } + /// + /// Whether the underlying Engine.io connection is established. + /// + public bool Connected => _client.Connected; + public async ValueTask DisposeAsync() { await _client.DisposeAsync().ConfigureAwait(false); + _connectLock.Dispose(); } /// @@ -85,15 +91,36 @@ public async ValueTask DisposeAsync() /// public async Task ConnectAsync(string? @namespace = default, CancellationToken cancellationToken = default) { - if (!_connected) + // Built before the connection is touched, so a namespace the protocol refuses + // does not leave a connection open behind it. + var packet = new Packet(PacketType.Connect, @namespace); + + await _connectLock.WaitAsync(cancellationToken).ConfigureAwait(false); + + try { - await _client.ConnectAsync(cancellationToken).ConfigureAwait(false); - _connected = true; + if (!_client.Connected) + { + await _client.ConnectAsync(cancellationToken).ConfigureAwait(false); + + // Engine.io reports a failed handshake through its packet stream + // rather than by throwing, so a caller that only ever sends would + // otherwise go on writing to a transport that never came up. + if (!_client.Connected) + { + throw new IOConnectionException( + "The Engine.io connection could not be established.", _client.ConnectionError); + } + } + } + finally + { + _connectLock.Release(); } // TODO: the server answers with `0{"sid":"..."}` for the namespace, which is // what _namespaces is waiting for. Recording it needs the inbound parser. - await SendPacketAsync(new Packet(PacketType.Connect, @namespace), cancellationToken).ConfigureAwait(false); + await SendPacketAsync(packet, cancellationToken).ConfigureAwait(false); } /// @@ -189,6 +216,12 @@ public Task SendAsync(ReadOnlyMemory data, string? @event = default, strin private async Task SendPacketAsync(Packet packet, CancellationToken cancellationToken) { + if (!_client.Connected) + { + throw new IOConnectionException( + $"Not connected. Call {nameof(ConnectAsync)} before sending.", _client.ConnectionError); + } + // The header travels as a plain-text Engine.io message; each attachment then // follows as its own binary message, in the order its placeholder named it. await _client.SendAsync(EnginePacket.CreateMessagePacket(packet.Serialize()), cancellationToken) diff --git a/tests/SocketIO.Client.Tests/IOTests.cs b/tests/SocketIO.Client.Tests/IOTests.cs index ce4013e..b3237a5 100644 --- a/tests/SocketIO.Client.Tests/IOTests.cs +++ b/tests/SocketIO.Client.Tests/IOTests.cs @@ -1,3 +1,5 @@ +using SocketIO.Client.Exceptions; + namespace SocketIO.Client.Tests; public class IOTests @@ -11,6 +13,7 @@ async Task Should_Send_Connect_Packet_After_The_Handshake() { await io.ConnectAsync(); + Assert.True(io.Connected); Assert.Equal("40", Assert.Single(server.Posts)); } } @@ -42,6 +45,50 @@ async Task Should_Serve_From_The_Socket_IO_Path() } } + [Fact(DisplayName = "A handshake that never completed is not reported as a connection")] + async Task ConnectAsync_Should_Throw_When_The_Handshake_Fails() + { + // Engine.io answers a handshake with an Open packet; anything else is a + // protocol error rather than a session. + var (io, server) = CreateClient(FakePollingServer.Payload("4nonsense")); + + await using (io) + { + var exception = await Assert.ThrowsAsync(() => io.ConnectAsync()); + + Assert.NotNull(exception.InnerException); + Assert.False(io.Connected); + Assert.Empty(server.Posts); + } + } + + [Fact(DisplayName = "A failed attempt does not leave the client thinking it is connected")] + async Task ConnectAsync_Should_Handshake_Again_After_A_Failed_Attempt() + { + var (io, server) = CreateClient(FakePollingServer.Payload("4nonsense"), FakePollingServer.Handshake()); + + await using (io) + { + await Assert.ThrowsAsync(() => io.ConnectAsync()); + + await io.ConnectAsync(); + + Assert.True(io.Connected); + Assert.Equal("40", Assert.Single(server.Posts)); + } + } + + [Fact] + async Task SendAsync_Should_Throw_When_Not_Connected() + { + var (io, _) = CreateClient(FakePollingServer.Handshake()); + + await using (io) + { + await Assert.ThrowsAsync(() => io.SendAsync("Hello!")); + } + } + [Fact] async Task Should_Send_A_Text_Event() { From 326f8659e03fd934c8bc265d139b3aecf2a5106e Mon Sep 17 00:00:00 2001 From: redouane Date: Tue, 8 Sep 2026 20:09:06 +0200 Subject: [PATCH 17/21] fix(socketio): keep a binary packet and its attachments together MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A binary packet is several Engine.io packets that a decoder reads as one run: the header, then exactly as many binary packets as it announced. The transports serialize sends one at a time, but nothing held a run together, so two concurrent SendAsync(byte[]) calls could put hdrA, hdrB, attA, attB on the wire. The decoder fails that on the second header — "got plaintext data when reconstructing a packet" — and the server closes the connection. Verified by removing the lock again: the test fails. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CsxPBM9F82FBXVQDV1jFmN --- src/SocketIO.Client/IO.cs | 34 ++++++++++++++--- .../FakePollingServer.cs | 11 ++++++ tests/SocketIO.Client.Tests/IOTests.cs | 37 ++++++++++++++++++- 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/src/SocketIO.Client/IO.cs b/src/SocketIO.Client/IO.cs index 813490f..6730a7d 100644 --- a/src/SocketIO.Client/IO.cs +++ b/src/SocketIO.Client/IO.cs @@ -38,6 +38,17 @@ public sealed class IO : IAsyncDisposable /// private readonly SemaphoreSlim _connectLock = new(1, 1); + /// + /// Holds a packet and its attachments together on the wire. + /// + /// + /// A binary packet is several Engine.io packets that a decoder reads as one + /// run: the header, then exactly as many binary packets as it announced. + /// Another packet landing in the middle of that run is a protocol error, and + /// the transports only serialize sends one at a time — not in groups. + /// + private readonly SemaphoreSlim _sendLock = new(1, 1); + public IO(string baseAddress, string path = DefaultPath, ILoggerFactory? loggerFactory = null) { Path = path; @@ -82,6 +93,7 @@ public async ValueTask DisposeAsync() { await _client.DisposeAsync().ConfigureAwait(false); _connectLock.Dispose(); + _sendLock.Dispose(); } /// @@ -222,15 +234,25 @@ private async Task SendPacketAsync(Packet packet, CancellationToken cancellation $"Not connected. Call {nameof(ConnectAsync)} before sending.", _client.ConnectionError); } - // The header travels as a plain-text Engine.io message; each attachment then - // follows as its own binary message, in the order its placeholder named it. - await _client.SendAsync(EnginePacket.CreateMessagePacket(packet.Serialize()), cancellationToken) - .ConfigureAwait(false); + await _sendLock.WaitAsync(cancellationToken).ConfigureAwait(false); - foreach (var attachment in packet.Attachments) + try { - await _client.SendAsync(EnginePacket.CreateBinaryPacket(attachment), cancellationToken) + // The header travels as a plain-text Engine.io message; each attachment + // then follows as its own binary message, in the order its placeholder + // named it. Nothing may come between them. + await _client.SendAsync(EnginePacket.CreateMessagePacket(packet.Serialize()), cancellationToken) .ConfigureAwait(false); + + foreach (var attachment in packet.Attachments) + { + await _client.SendAsync(EnginePacket.CreateBinaryPacket(attachment), cancellationToken) + .ConfigureAwait(false); + } + } + finally + { + _sendLock.Release(); } } } \ No newline at end of file diff --git a/tests/SocketIO.Client.Tests/FakePollingServer.cs b/tests/SocketIO.Client.Tests/FakePollingServer.cs index 0ae123b..d40546b 100644 --- a/tests/SocketIO.Client.Tests/FakePollingServer.cs +++ b/tests/SocketIO.Client.Tests/FakePollingServer.cs @@ -37,6 +37,12 @@ public FakePollingServer(params byte[][] pollResponses) /// public TimeSpan PollDelay { get; init; } = TimeSpan.FromMilliseconds(20); + /// + /// How long a POST takes. A send that overlaps another is what makes an + /// interleaving visible, so the ordering tests widen this window. + /// + public TimeSpan PostDelay { get; init; } = TimeSpan.Zero; + public IReadOnlyList Requests { get @@ -81,6 +87,11 @@ protected override async Task SendAsync( if (request.Method != HttpMethod.Get) { + if (PostDelay > TimeSpan.Zero) + { + await Task.Delay(PostDelay, cancellationToken); + } + return Ok("ok"u8.ToArray()); } diff --git a/tests/SocketIO.Client.Tests/IOTests.cs b/tests/SocketIO.Client.Tests/IOTests.cs index b3237a5..a2858e3 100644 --- a/tests/SocketIO.Client.Tests/IOTests.cs +++ b/tests/SocketIO.Client.Tests/IOTests.cs @@ -121,11 +121,46 @@ async Task Should_Send_A_Binary_Event_As_Header_Then_Attachment() } } + [Fact(DisplayName = "Concurrent binary sends do not interleave their attachments")] + async Task Should_Keep_A_Binary_Packet_And_Its_Attachments_Together() + { + const int senders = 16; + + // A send that overlaps another is what would let a second header land between + // a first header and the attachment it announced. + var (io, server) = CreateClient(TimeSpan.FromMilliseconds(5), FakePollingServer.Handshake()); + + await using (io) + { + await io.ConnectAsync(); + + await Task.WhenAll(Enumerable.Range(0, senders) + .Select(i => io.SendAsync(new byte[] { (byte)i })) + .ToArray()); + + // The CONNECT packet, then one header and one attachment per sender. + var posts = server.Posts.Skip(1).ToArray(); + Assert.Equal(senders * 2, posts.Length); + + for (var i = 0; i < senders; i++) + { + Assert.StartsWith("451-", posts[i * 2]); + Assert.StartsWith("b", posts[(i * 2) + 1]); + } + } + } + private static (IO Client, FakePollingServer Server) CreateClient(params byte[][] pollResponses) + { + return CreateClient(TimeSpan.Zero, pollResponses); + } + + private static (IO Client, FakePollingServer Server) CreateClient( + TimeSpan postDelay, params byte[][] pollResponses) { // No websocket among the advertised upgrades, so the client stays on polling // and every packet it sends is a POST this server can be asked about. - var server = new FakePollingServer(pollResponses); + var server = new FakePollingServer(pollResponses) { PostDelay = postDelay }; var httpClient = new HttpClient(server) { BaseAddress = new Uri("http://foo.bar") }; return (new IO(httpClient, "http://foo.bar"), server); From 856ac0a31bcd95b822ee364dbfdebbbd0c754297 Mon Sep 17 00:00:00 2001 From: redouane Date: Tue, 8 Sep 2026 20:09:15 +0200 Subject: [PATCH 18/21] docs: record the reference decoder and the client test seam Two things this session leaned on that are not obvious from the code: socket.io-parser in node_modules settles what the wire accepts faster and more reliably than the spec does, and SocketIO.Client now reaches EngineIO.Client's internals so IO can be driven from a stubbed handler. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CsxPBM9F82FBXVQDV1jFmN --- CLAUDE.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2cbe311..b2fc4df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,12 @@ Protocol references: - Engine.IO: https://socket.io/docs/v4/engine-io-protocol - Socket.IO: https://socket.io/docs/v4/socket-io-protocol +The reference implementation is in `node_modules` once `npm install` has run, and it is the arbiter when a question is +about what the wire actually accepts: `node_modules/socket.io-parser` decodes a string the C# side produced, which is a +faster and surer check than reading the spec. Its rules are stricter than they look — a binary packet must announce at +least one attachment, an event may not be named after a reserved one, and a decode error costs the whole connection +rather than the one packet. + ## Commands ```bash @@ -79,9 +85,11 @@ values being the *ASCII byte* of the digit (`Open = 0x30`, i.e. `'0'`) so parsin - `.editorconfig` is authoritative and unusually strict: `end_of_line = crlf`, `insert_final_newline = false`, `var` is disallowed where the type is not apparent, and `using` groups are separated with `System` first. Run `dotnet format` rather than hand-matching it. -- `AssemblyInfo.cs` grants `InternalsVisibleTo("EngineIO.Client.Tests")`. Transports expose `internal` constructors that - accept an injected `HttpClient` purely so tests can supply a mocked `HttpMessageHandler` — follow that pattern for new - transports instead of adding public seams. +- `AssemblyInfo.cs` grants `InternalsVisibleTo` to the matching test project, and `EngineIO.Client` also grants it to + `SocketIO.Client` so the layer above can reach the same seam. `Engine`, `IO` and the transports expose `internal` + constructors that accept an injected `HttpClient` purely so tests can supply a stubbed handler — follow that pattern + instead of adding public seams. `EngineIO.Client.Tests` mocks the handler with Moq; `SocketIO.Client.Tests` takes no + such dependency and uses the hand-rolled `FakePollingServer`. - Logging is optional throughout: `ILoggerFactory` is nullable everywhere and callers may pass nothing. ## Other agent configs present From 284ff140f82c02ef8ba1fcd910456b325b124ab2 Mon Sep 17 00:00:00 2001 From: redouane Date: Tue, 8 Sep 2026 23:38:42 +0200 Subject: [PATCH 19/21] refactor(socketio): split the packet type by direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packet was an accumulator: construct it, AddItem until the payload is complete, serialize. None of that describes what arrives from the server, where the arguments already exist and want reading. A single type would have had half its members meaningless in either direction. So the outbound type becomes PacketBuilder, which is what it always was, and Packet is now the inbound one — the plain name going to the type consumers actually handle, since every listener writes `await foreach` and only some code builds a packet by hand. IO.ListenAsync already declared IAsyncEnumerable while throwing; that signature is now true. Packet carries its public API and no implementation yet, so the surface can be reviewed before the parser is written against it. The rename and the new declaration land together because ListenAsync's signature names both, and neither compiles alone. The grammar predicates move onto PacketType, where they describe the thing they are about and neither side can drift from the other. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CsxPBM9F82FBXVQDV1jFmN --- socketio-packet-parsing.md | 88 +++++ src/SocketIO.Client/IO.cs | 12 +- src/SocketIO.Client/Packets/Packet.cs | 372 +++--------------- src/SocketIO.Client/Packets/PacketBuilder.cs | 371 +++++++++++++++++ src/SocketIO.Client/Packets/PacketType.cs | 34 ++ .../Packets/BinaryEventPacketTests.cs | 28 +- .../Packets/ConnectPacketTests.cs | 14 +- .../Packets/DisconnectPacketTests.cs | 10 +- .../Packets/EventPacketTests.cs | 46 +-- 9 files changed, 610 insertions(+), 365 deletions(-) create mode 100644 socketio-packet-parsing.md create mode 100644 src/SocketIO.Client/Packets/PacketBuilder.cs diff --git a/socketio-packet-parsing.md b/socketio-packet-parsing.md new file mode 100644 index 0000000..bbcdd13 --- /dev/null +++ b/socketio-packet-parsing.md @@ -0,0 +1,88 @@ +`Engine.ListenAsync` already hands you exactly the right input: `Message` packets in arrival order, each flagged `PlainText` or `Binary`. So the parser's input is a stream, not a buffer — and that's the one place the Engine.IO mirror breaks down. + +```mermaid +sequenceDiagram + participant E as Engine.ListenAsync + participant D as Decoder (stateful) + participant IO as IO.ListenAsync + E-->>D: Message "51-[\"blob\",{_placeholder,num:0}]" + Note over D: header parsed, 1 attachment owed → hold + E-->>D: Message + Note over D: owed 0 → complete + D-->>IO: Packet(BinaryEvent, "blob", [bytes]) +``` + +`Packet.TryParse` on the Engine.IO side is a cast and a slice because an Engine.IO packet is self-contained. A Socket.IO binary packet spans several of them, so the job splits in two: a **pure** `TryParse` for one text frame, and a **stateful** `Decoder` that owns the "waiting for N attachments" state. Putting the second inside `TryParse` is the trap — it would make a parse call order-dependent. + +```diff + src/SocketIO.Client/ + ├── IO.cs # ListenAsync stops throwing; drains Engine + routes + ├── Packets/ + │ ├── Packet.cs # + static TryParse, + read-side payload accessors + │ ├── PacketData.cs + │ └── PacketType.cs ++│ └── Decoder.cs # holds a binary header until its attachments land + └── Exceptions/ ++ └── PacketFormatException.cs +``` + +## The header scan + +Straight left-to-right, each part optional but strictly ordered — `[-][,][]`: + +```text +TryParse(bytes) -> bool + type = bytes[0] reject unless 0x30..0x36 + + if type is BinaryEvent or BinaryAck + read digits up to '-' reject if no '-' or count < 1 + attachments = count + + if next byte is '/' + read up to ',' or end of buffer namespace, else "/" + + if next byte is a digit + read digits ackId + + payload = rest empty for Connect/Disconnect +``` + +The `count < 1` rejection is the same rule that bit the encoder — worth mirroring so a malformed inbound frame fails here rather than three layers up. + +## The stateful half + +```text +Decoder.Add(enginePacket) -> Packet? + if packet is binary + if not reconstructing -> throw "binary with no header" + pending.Attach(bytes) + return pending.Complete ? Take() : null + + if reconstructing -> throw "header while reconstructing" + + if not Packet.TryParse(bytes, out p) -> throw + if p.Attachments == 0 -> return p + pending = p; return null +``` + +Both throws matter: they're precisely the two errors socket.io's own decoder raises, and they're what the send-side lock exists to avoid producing. Treat them the way the server does — fatal to the connection, not to the message. + +## The decision worth making first + +`Packet` is currently write-only: `List` accumulates items *to serialize*. Reading needs the inverse, and placeholders can't simply be substituted into JSON — bytes aren't JSON. So parse the payload array once into positional slots: + +```csharp +// read side, alongside the existing write side +private JsonDocument? _payload; // parsed once, owned by the packet +private readonly List _binarySlots = []; // arg index -> attachment index + +public string? Event { get; } // already there; args[0] for events +public int Count { get; } +public bool IsBinary(int index); +public T? GetItem(int index); // deserialize on demand +public ReadOnlyMemory GetAttachment(int index); +``` + +Deserializing lazily is what lets the packet exist before its attachments do — and it keeps the `JsonTypeInfo` seam (remaining item 9) a one-line change later instead of a rewrite. + +Two things I'd settle before writing code: whether `Packet` carries both directions or reading gets its own type (mirroring Engine.IO says one type, but that `Packet` is a 3-field struct, not this), and whether `IO.ListenAsync` filters by namespace in the enumerator or fans out to a channel per namespace — item 4 leans on whichever you pick. Want me to draft either? \ No newline at end of file diff --git a/src/SocketIO.Client/IO.cs b/src/SocketIO.Client/IO.cs index 6730a7d..0aedb47 100644 --- a/src/SocketIO.Client/IO.cs +++ b/src/SocketIO.Client/IO.cs @@ -105,7 +105,7 @@ public async Task ConnectAsync(string? @namespace = default, CancellationToken c { // Built before the connection is touched, so a namespace the protocol refuses // does not leave a connection open behind it. - var packet = new Packet(PacketType.Connect, @namespace); + var packet = new PacketBuilder(PacketType.Connect, @namespace); await _connectLock.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -142,7 +142,7 @@ public async Task ConnectAsync(string? @namespace = default, CancellationToken c /// public async Task DisconnectAsync(string? @namespace = default, CancellationToken cancellationToken = default) { - var packet = new Packet(PacketType.Disconnect, @namespace); + var packet = new PacketBuilder(PacketType.Disconnect, @namespace); await SendPacketAsync(packet, cancellationToken).ConfigureAwait(false); _namespaces.Remove(packet.Namespace); } @@ -173,7 +173,7 @@ public IAsyncEnumerable ListenAsync( public Task SendAsync(string text, string? @event = default, string? @namespace = default, CancellationToken cancellationToken = default) { - var packet = new Packet(PacketType.Event, @namespace, @event); + var packet = new PacketBuilder(PacketType.Event, @namespace, @event); packet.AddItem(text); return SendPacketAsync(packet, cancellationToken); } @@ -189,7 +189,7 @@ public Task SendAsync(string text, string? @event = default, string? @namespace public Task SendAsync(T data, string? @event = default, string? @namespace = default, CancellationToken cancellationToken = default) where T : class { - var packet = new Packet(PacketType.Event, @namespace, @event); + var packet = new PacketBuilder(PacketType.Event, @namespace, @event); packet.AddItem(data); return SendPacketAsync(packet, cancellationToken); } @@ -221,12 +221,12 @@ public Task SendAsync(byte[] data, string? @event = default, string? @namespace public Task SendAsync(ReadOnlyMemory data, string? @event = default, string? @namespace = default, CancellationToken cancellationToken = default) { - var packet = new Packet(PacketType.BinaryEvent, @namespace, @event); + var packet = new PacketBuilder(PacketType.BinaryEvent, @namespace, @event); packet.AddItem(data); return SendPacketAsync(packet, cancellationToken); } - private async Task SendPacketAsync(Packet packet, CancellationToken cancellationToken) + private async Task SendPacketAsync(PacketBuilder packet, CancellationToken cancellationToken) { if (!_client.Connected) { diff --git a/src/SocketIO.Client/Packets/Packet.cs b/src/SocketIO.Client/Packets/Packet.cs index fced04c..d7e1a5e 100644 --- a/src/SocketIO.Client/Packets/Packet.cs +++ b/src/SocketIO.Client/Packets/Packet.cs @@ -1,380 +1,132 @@ using System; -using System.Buffers; -using System.Collections.Generic; -using System.Text; -using System.Text.Json; namespace SocketIO.Client.Packets; /// -/// Represent a Socket.IO packet. see: https://socket.io/docs/v4/socket-io-protocol +/// A Socket.IO packet that arrived from the server. +/// see: https://socket.io/docs/v4/socket-io-protocol /// /// /// -/// The wire format is a header followed by a JSON payload: -/// <type>[<# of binary attachments>-][<namespace>,][<ack id>]<JSON payload>. +/// The counterpart of , which accumulates the +/// arguments of a packet to send. Two types rather than one because the two +/// sets of operations are never both valid, and because a merged type would +/// carry half its fields dead on every instance. /// /// -/// Binary arguments never appear in that JSON. Each leaves a placeholder behind -/// and travels as its own packet after the header — see . +/// The payload is kept as the bytes it arrived in and read only when an +/// argument is asked for, so a packet nobody inspects costs no more than its +/// header. Holding the memory is safe: both transports copy a message out of +/// their receive buffer before handing it up, so nothing else owns it. +/// +/// +/// Indices address the data arguments alone. An event names itself in the +/// first element of the payload array, and that element is surfaced as +/// rather than as argument zero. /// /// public sealed class Packet { /// - /// The namespace every connection starts in. + /// The namespace every connection starts in, and the one a packet belongs to + /// when its header names none. /// public const string DefaultNamespace = "/"; - /// - /// The event name used when the caller does not name one. - /// - public const string DefaultEventName = "message"; - - public static readonly Packet ConnectPacket = new(PacketType.Connect); - - public static readonly Packet DisconnectPacket = new(PacketType.Disconnect); - - private readonly List _data = new(); - - private readonly List> _attachments = new(); - - public Packet(PacketType type) - : this(type, null, null, null) - { - } - - public Packet(PacketType type, string? @namespace) - : this(type, @namespace, null, null) - { - } - - public Packet(PacketType type, string? @namespace, string? @event) - : this(type, @namespace, @event, null) - { - } - - public Packet(PacketType type, int ackId, string? @namespace, string? @event) - : this(type, @namespace, @event, ackId) - { - } - - /// - /// The one constructor that validates, so that no combination reaches the - /// wire without having been checked. - /// - private Packet(PacketType type, string? @namespace, string? @event, int? ackId) - { - if (!Enum.IsDefined(type)) - { - throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown packet type."); - } - - if (@event is not null && !CarriesEventName(type)) - { - throw new ArgumentException($"A {type} packet does not carry an event name.", nameof(@event)); - } - - if (ackId.HasValue && !CarriesAckId(type)) - { - throw new ArgumentException($"A {type} packet cannot carry an acknowledgement id.", nameof(type)); - } - - if (ackId is < 0) - { - throw new ArgumentOutOfRangeException(nameof(ackId), ackId, - "An acknowledgement id is a non-negative number; a sign would be read as the start of the payload."); - } - - // An acknowledgement that names no id answers nothing: the server looks the id - // up among the callbacks it is waiting on and discards the packet when it is - // missing, so it is refused here rather than sent into the void. - if (!ackId.HasValue && type is PacketType.Ack or PacketType.BinaryAck) - { - throw new ArgumentException($"A {type} packet has to name the acknowledgement it answers.", - nameof(ackId)); - } - - Type = type; - Namespace = NormalizeNamespace(@namespace); - AckId = ackId; - - if (!CarriesEventName(type)) - { - return; - } - - // The event name is not header material: it is the first argument of the - // payload array, which is why it is seeded as an item like any other. - Event = @event ?? DefaultEventName; - - if (IsReservedEventName(Event)) - { - throw new ArgumentException( - $"\"{Event}\" is reserved by the protocol; a packet naming it is rejected by the server.", - nameof(@event)); - } - - _data.Add(new TextPacketData(Event)); - } - /// /// Represents packet type. /// - public PacketType Type { get; } + public PacketType Type => throw new NotImplementedException(); /// /// Namespace this packet belongs to, always in its leading-slash form. /// - public string Namespace { get; } + public string Namespace => throw new NotImplementedException(); /// - /// Acknowledgement id correlating an event with its acknowledgement, when the - /// packet takes part in one. - /// - public int? AckId { get; } - - /// - /// Event name for the types that carry one, otherwise null. + /// Acknowledgement id this packet carries, when it takes part in one. /// /// - /// An acknowledgement answers an event rather than naming one, so its payload - /// holds the response arguments alone. + /// On an event it is the id the server expects an acknowledgement under; on an + /// acknowledgement it is the id of the event being answered. /// - public string? Event { get; } - - /// - /// The binary arguments, in the order their placeholders reference them. Each - /// is sent as a separate binary packet after this one. - /// - public IReadOnlyList> Attachments => _attachments; + public int? AckId => throw new NotImplementedException(); /// - /// Add plain text data to packet. + /// Event name for the types that carry one, otherwise null. /// - /// Plain text data - public void AddItem(string data) - { - AddPacketData(new TextPacketData(data)); - } + public string? Event => throw new NotImplementedException(); /// - /// Add Json serializable POCO. + /// Number of data arguments, not counting the event name. /// - /// Data instance - /// Data type - public void AddItem(T data) where T : class - { - AddPacketData(new JsonPacketData(data)); - } + public int Count => throw new NotImplementedException(); /// - /// Add binary data. + /// Parse the text part of a packet: everything up to and including the JSON + /// payload, but not the binary attachments it may announce. /// /// - /// Present so that a byte array reaches the binary overload rather than - /// , which would quietly encode it as a base64 string. + /// Deliberately unaware of attachments, so that parsing stays a pure function + /// of one buffer. Reassembling a packet that spans several Engine.io messages + /// is the 's job, and keeping it there is what stops a + /// parse call from depending on the order it was made in. /// - /// Binary data - public void AddItem(byte[] data) + /// The packet as it came off the wire + /// Parsed packet instance + /// Boolean indicating success or failure of parse operation + public static bool TryParse(ReadOnlyMemory data, out Packet? packet) { - AddItem(new ReadOnlyMemory(data)); + throw new NotImplementedException(); } /// - /// Add binary data. + /// Whether the argument at arrived as a binary + /// attachment rather than as a Json value. /// - /// Binary data - public void AddItem(ReadOnlyMemory data) + public bool IsBinary(int index) { - if (Type is not (PacketType.BinaryEvent or PacketType.BinaryAck)) - { - throw new InvalidOperationException( - $"A {Type} packet cannot carry binary data; use {nameof(PacketType.BinaryEvent)} " + - $"or {nameof(PacketType.BinaryAck)}."); - } - - AddPacketData(new BinaryPacketData(_attachments.Count, data)); - _attachments.Add(data); + throw new NotImplementedException(); } /// - /// Append an argument to the payload. + /// Deserialize the argument at . /// - /// - /// Named apart from the public AddItem overloads on purpose: an - /// is a class, so an overload by that name would - /// bind to and recurse into itself. - /// - private void AddPacketData(IPacketData data) + /// Type to deserialize the argument as + public T? GetItem(int index) { - if (Type is PacketType.Connect or PacketType.Disconnect) - { - throw new InvalidOperationException($"A {Type} packet does not carry a payload."); - } - - _data.Add(data); + throw new NotImplementedException(); } /// - /// Serialize the packet header and payload to their wire representation. + /// The bytes of the argument at , which has to be one + /// reports as binary. /// - /// - /// The result is the text part only. Anything in - /// follows it as separate packets. - /// - /// The encoded packet - internal ReadOnlyMemory Serialize() + public ReadOnlyMemory GetAttachment(int index) { - var buffer = new ArrayBufferWriter(); - Serialize(buffer); - return buffer.WrittenMemory; + throw new NotImplementedException(); } /// - /// Serialize the packet into a caller-owned buffer. + /// How many attachments the header announced. /// - /// - /// Writing rather than returning keeps the packet stateless, so the same - /// instance — among them — can be sent repeatedly. - /// - internal void Serialize(IBufferWriter writer) - { - // A decoder reads the announced count and refuses anything below one, so a - // binary packet with nothing attached is not an empty packet — it is one the - // server drops the connection over. It is caught here rather than in the - // constructor because the attachments arrive after it. - if ((Type is PacketType.BinaryEvent or PacketType.BinaryAck) && _attachments.Count == 0) - { - throw new InvalidOperationException( - $"A {Type} packet has to carry at least one binary argument; " + - $"use {nameof(PacketType.Event)} or {nameof(PacketType.Ack)} for a payload that has none."); - } - - WriteHeader(writer); - WritePayload(writer); - } - - private void WriteHeader(IBufferWriter writer) - { - WriteByte(writer, (byte)Type); - - // The count and its dash are what mark a type 5 or 6 packet as binary, so they - // are written for the type rather than for the attachments happening to be - // there — Serialize has already refused the packet if they are not. - if (Type is PacketType.BinaryEvent or PacketType.BinaryAck) - { - WriteInt32(writer, _attachments.Count); - WriteByte(writer, (byte)'-'); - } - - // The default namespace is implied by its absence. - if (!string.Equals(Namespace, DefaultNamespace, StringComparison.Ordinal)) - { - var length = Encoding.UTF8.GetByteCount(Namespace); - var span = writer.GetSpan(length + 1); - Encoding.UTF8.GetBytes(Namespace, span); - span[length] = (byte)','; - writer.Advance(length + 1); - } - - if (AckId.HasValue) - { - WriteInt32(writer, AckId.Value); - } - } - - private void WritePayload(IBufferWriter writer) - { - if (Type is PacketType.Connect or PacketType.Disconnect) - { - return; - } - - using var json = new Utf8JsonWriter(writer); - - // CONNECT_ERROR is the one payload that is not an argument list: it is the - // error object on its own. - if (Type == PacketType.ConnectError) - { - if (_data.Count > 0) - { - _data[0].Serialize(json); - } - - json.Flush(); - return; - } - - json.WriteStartArray(); - - foreach (var item in _data) - { - item.Serialize(json); - } - - json.WriteEndArray(); - json.Flush(); - } - - private static void WriteByte(IBufferWriter writer, byte value) - { - writer.GetSpan(1)[0] = value; - writer.Advance(1); - } - - private static void WriteInt32(IBufferWriter writer, int value) - { - // Room for every digit an int can produce, sign included. - var span = writer.GetSpan(11); - value.TryFormat(span, out var written); - writer.Advance(written); - } + internal int AttachmentCount => throw new NotImplementedException(); /// - /// Bring a namespace to the single form the wire uses, so that "admin", - /// "/admin" and a missing namespace do not encode three different ways. - /// - private static string NormalizeNamespace(string? @namespace) - { - if (string.IsNullOrWhiteSpace(@namespace)) - { - return DefaultNamespace; - } - - var trimmed = @namespace!.Trim(); - - // The comma is what ends the namespace in the header, so one inside it would - // truncate the name and leave the remainder to be parsed as the payload. - if (trimmed.Contains(',')) - { - throw new ArgumentException("A namespace cannot contain a comma; it is the header's separator.", - nameof(@namespace)); - } - - return trimmed.StartsWith('/') ? trimmed : "/" + trimmed; - } - - /// - /// Whether an event name is one the protocol keeps for itself. + /// Whether every attachment the header announced has arrived. /// /// - /// A server rejects a packet whose first argument is one of these, and a - /// rejected packet costs the whole connection rather than just the message. + /// True from the outset for a packet that announced none, which is every + /// packet that is not a binary one. /// - private static bool IsReservedEventName(string @event) - { - return @event is "connect" or "connect_error" or "disconnect" or "disconnecting" - or "newListener" or "removeListener"; - } + internal bool IsComplete => throw new NotImplementedException(); - private static bool CarriesEventName(PacketType type) - { - return type is PacketType.Event or PacketType.BinaryEvent; - } - - private static bool CarriesAckId(PacketType type) + /// + /// Take delivery of the next attachment, in the order the placeholders in the + /// payload refer to them. + /// + internal void Attach(ReadOnlyMemory attachment) { - return type is PacketType.Event or PacketType.Ack or PacketType.BinaryEvent or PacketType.BinaryAck; + throw new NotImplementedException(); } } \ No newline at end of file diff --git a/src/SocketIO.Client/Packets/PacketBuilder.cs b/src/SocketIO.Client/Packets/PacketBuilder.cs new file mode 100644 index 0000000..b974fb5 --- /dev/null +++ b/src/SocketIO.Client/Packets/PacketBuilder.cs @@ -0,0 +1,371 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Text; +using System.Text.Json; + +namespace SocketIO.Client.Packets; + +/// +/// Builds a Socket.IO packet to send. see: https://socket.io/docs/v4/socket-io-protocol +/// +/// +/// +/// The wire format is a header followed by a JSON payload: +/// <type>[<# of binary attachments>-][<namespace>,][<ack id>]<JSON payload>. +/// +/// +/// Binary arguments never appear in that JSON. Each leaves a placeholder behind +/// and travels as its own packet after the header — see . +/// +/// +/// The counterpart of , which reads the arguments that +/// arrived. Two types rather than one because the two sets of operations are +/// never both valid: there is nothing to read on a packet being built, and +/// nothing to add to one that came off the wire. +/// +/// +public sealed class PacketBuilder +{ + /// + /// The event name used when the caller does not name one. + /// + public const string DefaultEventName = "message"; + + public static readonly PacketBuilder Connect = new(PacketType.Connect); + + public static readonly PacketBuilder Disconnect = new(PacketType.Disconnect); + + private readonly List _data = new(); + + private readonly List> _attachments = new(); + + public PacketBuilder(PacketType type) + : this(type, null, null, null) + { + } + + public PacketBuilder(PacketType type, string? @namespace) + : this(type, @namespace, null, null) + { + } + + public PacketBuilder(PacketType type, string? @namespace, string? @event) + : this(type, @namespace, @event, null) + { + } + + public PacketBuilder(PacketType type, int ackId, string? @namespace, string? @event) + : this(type, @namespace, @event, ackId) + { + } + + /// + /// The one constructor that validates, so that no combination reaches the + /// wire without having been checked. + /// + private PacketBuilder(PacketType type, string? @namespace, string? @event, int? ackId) + { + if (!Enum.IsDefined(type)) + { + throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown packet type."); + } + + if (@event is not null && !type.CarriesEventName()) + { + throw new ArgumentException($"A {type} packet does not carry an event name.", nameof(@event)); + } + + if (ackId.HasValue && !type.CarriesAckId()) + { + throw new ArgumentException($"A {type} packet cannot carry an acknowledgement id.", nameof(type)); + } + + if (ackId is < 0) + { + throw new ArgumentOutOfRangeException(nameof(ackId), ackId, + "An acknowledgement id is a non-negative number; a sign would be read as the start of the payload."); + } + + // An acknowledgement that names no id answers nothing: the server looks the id + // up among the callbacks it is waiting on and discards the packet when it is + // missing, so it is refused here rather than sent into the void. + if (!ackId.HasValue && type is PacketType.Ack or PacketType.BinaryAck) + { + throw new ArgumentException($"A {type} packet has to name the acknowledgement it answers.", + nameof(ackId)); + } + + Type = type; + Namespace = NormalizeNamespace(@namespace); + AckId = ackId; + + if (!type.CarriesEventName()) + { + return; + } + + // The event name is not header material: it is the first argument of the + // payload array, which is why it is seeded as an item like any other. + Event = @event ?? DefaultEventName; + + if (IsReservedEventName(Event)) + { + throw new ArgumentException( + $"\"{Event}\" is reserved by the protocol; a packet naming it is rejected by the server.", + nameof(@event)); + } + + _data.Add(new TextPacketData(Event)); + } + + /// + /// Represents packet type. + /// + public PacketType Type { get; } + + /// + /// Namespace this packet belongs to, always in its leading-slash form. + /// + public string Namespace { get; } + + /// + /// Acknowledgement id correlating an event with its acknowledgement, when the + /// packet takes part in one. + /// + public int? AckId { get; } + + /// + /// Event name for the types that carry one, otherwise null. + /// + /// + /// An acknowledgement answers an event rather than naming one, so its payload + /// holds the response arguments alone. + /// + public string? Event { get; } + + /// + /// The binary arguments, in the order their placeholders reference them. Each + /// is sent as a separate binary packet after this one. + /// + public IReadOnlyList> Attachments => _attachments; + + /// + /// Add plain text data to packet. + /// + /// Plain text data + public void AddItem(string data) + { + AddPacketData(new TextPacketData(data)); + } + + /// + /// Add Json serializable POCO. + /// + /// Data instance + /// Data type + public void AddItem(T data) where T : class + { + AddPacketData(new JsonPacketData(data)); + } + + /// + /// Add binary data. + /// + /// + /// Present so that a byte array reaches the binary overload rather than + /// , which would quietly encode it as a base64 string. + /// + /// Binary data + public void AddItem(byte[] data) + { + AddItem(new ReadOnlyMemory(data)); + } + + /// + /// Add binary data. + /// + /// Binary data + public void AddItem(ReadOnlyMemory data) + { + if (!Type.CarriesAttachments()) + { + throw new InvalidOperationException( + $"A {Type} packet cannot carry binary data; use {nameof(PacketType.BinaryEvent)} " + + $"or {nameof(PacketType.BinaryAck)}."); + } + + AddPacketData(new BinaryPacketData(_attachments.Count, data)); + _attachments.Add(data); + } + + /// + /// Append an argument to the payload. + /// + /// + /// Named apart from the public AddItem overloads on purpose: an + /// is a class, so an overload by that name would + /// bind to and recurse into itself. + /// + private void AddPacketData(IPacketData data) + { + if (Type is PacketType.Connect or PacketType.Disconnect) + { + throw new InvalidOperationException($"A {Type} packet does not carry a payload."); + } + + _data.Add(data); + } + + /// + /// Serialize the packet header and payload to their wire representation. + /// + /// + /// The result is the text part only. Anything in + /// follows it as separate packets. + /// + /// The encoded packet + internal ReadOnlyMemory Serialize() + { + var buffer = new ArrayBufferWriter(); + Serialize(buffer); + return buffer.WrittenMemory; + } + + /// + /// Serialize the packet into a caller-owned buffer. + /// + /// + /// Writing rather than returning keeps the packet stateless, so the same + /// instance — among them — can be sent repeatedly. + /// + internal void Serialize(IBufferWriter writer) + { + // A decoder reads the announced count and refuses anything below one, so a + // binary packet with nothing attached is not an empty packet — it is one the + // server drops the connection over. It is caught here rather than in the + // constructor because the attachments arrive after it. + if (Type.CarriesAttachments() && _attachments.Count == 0) + { + throw new InvalidOperationException( + $"A {Type} packet has to carry at least one binary argument; " + + $"use {nameof(PacketType.Event)} or {nameof(PacketType.Ack)} for a payload that has none."); + } + + WriteHeader(writer); + WritePayload(writer); + } + + private void WriteHeader(IBufferWriter writer) + { + WriteByte(writer, (byte)Type); + + // The count and its dash are what mark a type 5 or 6 packet as binary, so they + // are written for the type rather than for the attachments happening to be + // there — Serialize has already refused the packet if they are not. + if (Type.CarriesAttachments()) + { + WriteInt32(writer, _attachments.Count); + WriteByte(writer, (byte)'-'); + } + + // The default namespace is implied by its absence. + if (!string.Equals(Namespace, Packet.DefaultNamespace, StringComparison.Ordinal)) + { + var length = Encoding.UTF8.GetByteCount(Namespace); + var span = writer.GetSpan(length + 1); + Encoding.UTF8.GetBytes(Namespace, span); + span[length] = (byte)','; + writer.Advance(length + 1); + } + + if (AckId.HasValue) + { + WriteInt32(writer, AckId.Value); + } + } + + private void WritePayload(IBufferWriter writer) + { + if (Type is PacketType.Connect or PacketType.Disconnect) + { + return; + } + + using var json = new Utf8JsonWriter(writer); + + // CONNECT_ERROR is the one payload that is not an argument list: it is the + // error object on its own. + if (Type == PacketType.ConnectError) + { + if (_data.Count > 0) + { + _data[0].Serialize(json); + } + + json.Flush(); + return; + } + + json.WriteStartArray(); + + foreach (var item in _data) + { + item.Serialize(json); + } + + json.WriteEndArray(); + json.Flush(); + } + + private static void WriteByte(IBufferWriter writer, byte value) + { + writer.GetSpan(1)[0] = value; + writer.Advance(1); + } + + private static void WriteInt32(IBufferWriter writer, int value) + { + // Room for every digit an int can produce, sign included. + var span = writer.GetSpan(11); + value.TryFormat(span, out var written); + writer.Advance(written); + } + + /// + /// Bring a namespace to the single form the wire uses, so that "admin", + /// "/admin" and a missing namespace do not encode three different ways. + /// + internal static string NormalizeNamespace(string? @namespace) + { + if (string.IsNullOrWhiteSpace(@namespace)) + { + return Packet.DefaultNamespace; + } + + var trimmed = @namespace!.Trim(); + + // The comma is what ends the namespace in the header, so one inside it would + // truncate the name and leave the remainder to be parsed as the payload. + if (trimmed.Contains(',')) + { + throw new ArgumentException("A namespace cannot contain a comma; it is the header's separator.", + nameof(@namespace)); + } + + return trimmed.StartsWith('/') ? trimmed : "/" + trimmed; + } + + /// + /// Whether an event name is one the protocol keeps for itself. + /// + /// + /// A server rejects a packet whose first argument is one of these, and a + /// rejected packet costs the whole connection rather than just the message. + /// + private static bool IsReservedEventName(string @event) + { + return @event is "connect" or "connect_error" or "disconnect" or "disconnecting" + or "newListener" or "removeListener"; + } +} \ No newline at end of file diff --git a/src/SocketIO.Client/Packets/PacketType.cs b/src/SocketIO.Client/Packets/PacketType.cs index 3784415..99186f8 100644 --- a/src/SocketIO.Client/Packets/PacketType.cs +++ b/src/SocketIO.Client/Packets/PacketType.cs @@ -44,4 +44,38 @@ public enum PacketType : byte /// Acknowledgement packet type with binary data. /// BinaryAck = 0x36 +} + +/// +/// What each packet type is allowed to carry. +/// +/// +/// The rules belong to the type rather than to either packet class, so that the +/// builder and the parser cannot drift apart on what a header may hold. +/// +internal static class PacketTypeExtensions +{ + /// + /// Whether the first payload argument is an event name. + /// + public static bool CarriesEventName(this PacketType type) + { + return type is PacketType.Event or PacketType.BinaryEvent; + } + + /// + /// Whether the header may hold an acknowledgement id. + /// + public static bool CarriesAckId(this PacketType type) + { + return type is PacketType.Event or PacketType.Ack or PacketType.BinaryEvent or PacketType.BinaryAck; + } + + /// + /// Whether the packet announces attachments and is followed by them. + /// + public static bool CarriesAttachments(this PacketType type) + { + return type is PacketType.BinaryEvent or PacketType.BinaryAck; + } } \ No newline at end of file diff --git a/tests/SocketIO.Client.Tests/Packets/BinaryEventPacketTests.cs b/tests/SocketIO.Client.Tests/Packets/BinaryEventPacketTests.cs index 2f35404..957878b 100644 --- a/tests/SocketIO.Client.Tests/Packets/BinaryEventPacketTests.cs +++ b/tests/SocketIO.Client.Tests/Packets/BinaryEventPacketTests.cs @@ -9,7 +9,7 @@ public class BinaryEventPacketTests [Fact] void Should_Create_Binary_Event_Packet() { - var packet = new Packet(PacketType.BinaryEvent); + var packet = new PacketBuilder(PacketType.BinaryEvent); packet.AddItem(new ReadOnlyMemory([1, 2, 3])); Assert.Equal(PacketType.BinaryEvent, packet.Type); @@ -21,7 +21,7 @@ void Should_Create_Binary_Packet_With_Namespace() { var @namespace = "test"; - var packet = new Packet(PacketType.BinaryEvent, @namespace); + var packet = new PacketBuilder(PacketType.BinaryEvent, @namespace); Assert.Equal($"/{@namespace}", packet.Namespace); } @@ -31,7 +31,7 @@ void Should_Create_Binary_Packet_With_Event_Name() { var eventName = "test"; - var packet = new Packet(PacketType.BinaryEvent, null, eventName); + var packet = new PacketBuilder(PacketType.BinaryEvent, null, eventName); Assert.Equal(eventName, packet.Event); } @@ -41,7 +41,7 @@ void Should_Create_Binary_Packet_With_Ack_Id() { var ackId = 42; - var packet = new Packet(PacketType.BinaryAck, ackId, null, null); + var packet = new PacketBuilder(PacketType.BinaryAck, ackId, null, null); Assert.Equal(ackId, packet.AckId); } @@ -49,7 +49,7 @@ void Should_Create_Binary_Packet_With_Ack_Id() [Fact] void Should_Serialize_Binary_Event_Packet() { - var packet = new Packet(PacketType.BinaryEvent); + var packet = new PacketBuilder(PacketType.BinaryEvent); packet.AddItem(new ReadOnlyMemory([1, 2, 3])); var encodedPacket = Encoding.UTF8.GetString(packet.Serialize().Span); @@ -63,7 +63,7 @@ void Should_Serialize_Binary_Packet_With_Namespace() var @namespace = "test"; var expectedEncodedPacket = $$"""51-/{{@namespace}},["message",{"_placeholder":true,"num":0}]"""; - var packet = new Packet(PacketType.BinaryEvent, @namespace); + var packet = new PacketBuilder(PacketType.BinaryEvent, @namespace); packet.AddItem(new ReadOnlyMemory([1, 2, 3])); Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); @@ -75,7 +75,7 @@ void Should_Serialize_Binary_Packet_With_Event_Name() var eventName = "test"; var expectedEncodedPacket = $$"""51-["{{eventName}}",{"_placeholder":true,"num":0}]"""; - var packet = new Packet(PacketType.BinaryEvent, null, eventName); + var packet = new PacketBuilder(PacketType.BinaryEvent, null, eventName); packet.AddItem(new ReadOnlyMemory([1, 2, 3])); Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); @@ -89,7 +89,7 @@ void Should_Serialize_Binary_Packet_With_Ack_Id() // An acknowledgement answers an event rather than naming one. var expectedEncodedPacket = $$"""61-{{ackId}}[{"_placeholder":true,"num":0}]"""; - var packet = new Packet(PacketType.BinaryAck, ackId, null, null); + var packet = new PacketBuilder(PacketType.BinaryAck, ackId, null, null); packet.AddItem(new ReadOnlyMemory([1, 2, 3])); Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); @@ -101,8 +101,8 @@ void Should_Serialize_Binary_Packet_With_Ack_Id() void Should_Reject_A_Binary_Packet_Without_An_Attachment(PacketType type) { var packet = type == PacketType.BinaryAck - ? new Packet(type, 1, null, null) - : new Packet(type); + ? new PacketBuilder(type, 1, null, null) + : new PacketBuilder(type); packet.AddItem("Hello!"); Assert.Throws(() => packet.Serialize()); @@ -116,7 +116,7 @@ void Should_Number_Attachments_In_Order() var expectedEncodedPacket = """52-["message",{"_placeholder":true,"num":0},{"_placeholder":true,"num":1}]"""; - var packet = new Packet(PacketType.BinaryEvent); + var packet = new PacketBuilder(PacketType.BinaryEvent); packet.AddItem(first); packet.AddItem(second); @@ -129,7 +129,7 @@ void Should_Mix_Text_And_Binary_Arguments() { var expectedEncodedPacket = """51-["message","Hello!",{"_placeholder":true,"num":0}]"""; - var packet = new Packet(PacketType.BinaryEvent); + var packet = new PacketBuilder(PacketType.BinaryEvent); packet.AddItem("Hello!"); packet.AddItem(new ReadOnlyMemory([1, 2, 3])); @@ -141,7 +141,7 @@ void Should_Treat_A_Byte_Array_As_Binary() { byte[] attachment = [1, 2, 3]; - var packet = new Packet(PacketType.BinaryEvent); + var packet = new PacketBuilder(PacketType.BinaryEvent); packet.AddItem(attachment); Assert.Equal("""51-["message",{"_placeholder":true,"num":0}]""", @@ -154,7 +154,7 @@ void Should_Keep_Attachments_Out_Of_The_Header() { var attachment = new ReadOnlyMemory([1, 2, 3]); - var packet = new Packet(PacketType.BinaryEvent); + var packet = new PacketBuilder(PacketType.BinaryEvent); packet.AddItem(attachment); Assert.Equal(attachment, Assert.Single(packet.Attachments)); diff --git a/tests/SocketIO.Client.Tests/Packets/ConnectPacketTests.cs b/tests/SocketIO.Client.Tests/Packets/ConnectPacketTests.cs index 73c9afc..d4858d3 100644 --- a/tests/SocketIO.Client.Tests/Packets/ConnectPacketTests.cs +++ b/tests/SocketIO.Client.Tests/Packets/ConnectPacketTests.cs @@ -9,7 +9,7 @@ public class ConnectPacketTests [Fact] void Should_Create_Connect_Packet() { - var packet = Packet.ConnectPacket; + var packet = PacketBuilder.Connect; Assert.Equal(PacketType.Connect, packet.Type); Assert.Equal("/", packet.Namespace); @@ -20,7 +20,7 @@ void Should_Create_Connect_Packet_With_Namespace() { var @namespace = "test"; - var packet = new Packet(PacketType.Connect, @namespace); + var packet = new PacketBuilder(PacketType.Connect, @namespace); Assert.Equal(PacketType.Connect, packet.Type); Assert.Equal($"/{@namespace}", packet.Namespace); @@ -29,7 +29,7 @@ void Should_Create_Connect_Packet_With_Namespace() [Fact] void Should_Serialize_Connect_Packet() { - var connectPacket = Packet.ConnectPacket; + var connectPacket = PacketBuilder.Connect; var encodedPacket = Encoding.UTF8.GetString(connectPacket.Serialize().Span); @@ -41,7 +41,7 @@ void Should_Serialize_Connect_Packet() void Should_Serialize_Connect_Packet_With_Namespace() { var @namespace = "test"; - var connectPacket = new Packet(PacketType.Connect, @namespace); + var connectPacket = new PacketBuilder(PacketType.Connect, @namespace); var encodedPacket = Encoding.UTF8.GetString(connectPacket.Serialize().Span); @@ -52,7 +52,7 @@ void Should_Serialize_Connect_Packet_With_Namespace() [Fact] void Should_Reject_A_Payload_On_A_Connect_Packet() { - var packet = Packet.ConnectPacket; + var packet = PacketBuilder.Connect; Assert.Throws(() => packet.AddItem("World")); } @@ -60,8 +60,8 @@ void Should_Reject_A_Payload_On_A_Connect_Packet() [Fact(DisplayName = "The shared Connect packet can be serialized more than once")] void Should_Serialize_The_Shared_Connect_Packet_Repeatedly() { - var first = Encoding.UTF8.GetString(Packet.ConnectPacket.Serialize().Span); - var second = Encoding.UTF8.GetString(Packet.ConnectPacket.Serialize().Span); + var first = Encoding.UTF8.GetString(PacketBuilder.Connect.Serialize().Span); + var second = Encoding.UTF8.GetString(PacketBuilder.Connect.Serialize().Span); Assert.Equal("0", first); Assert.Equal(first, second); diff --git a/tests/SocketIO.Client.Tests/Packets/DisconnectPacketTests.cs b/tests/SocketIO.Client.Tests/Packets/DisconnectPacketTests.cs index e259f37..a0735bf 100644 --- a/tests/SocketIO.Client.Tests/Packets/DisconnectPacketTests.cs +++ b/tests/SocketIO.Client.Tests/Packets/DisconnectPacketTests.cs @@ -9,7 +9,7 @@ public class DisconnectPacketTests [Fact] void Should_Create_Disconnect_Packet() { - var packet = Packet.DisconnectPacket; + var packet = PacketBuilder.Disconnect; Assert.Equal(PacketType.Disconnect, packet.Type); Assert.Equal("/", packet.Namespace); @@ -20,7 +20,7 @@ void Should_Create_Disconnect_Packet_With_Namespace() { var @namespace = "test"; - var packet = new Packet(PacketType.Disconnect, @namespace); + var packet = new PacketBuilder(PacketType.Disconnect, @namespace); Assert.Equal(PacketType.Disconnect, packet.Type); Assert.Equal($"/{@namespace}", packet.Namespace); @@ -29,7 +29,7 @@ void Should_Create_Disconnect_Packet_With_Namespace() [Fact] void Should_Serialize_Disconnect_Packet() { - var packet = Packet.DisconnectPacket; + var packet = PacketBuilder.Disconnect; var encodedPacket = Encoding.UTF8.GetString(packet.Serialize().Span); @@ -41,7 +41,7 @@ void Should_Serialize_Disconnect_Packet() void Should_Serialize_Disconnect_Packet_With_Namespace() { var @namespace = "test"; - var packet = new Packet(PacketType.Disconnect, @namespace); + var packet = new PacketBuilder(PacketType.Disconnect, @namespace); var encodedPacket = Encoding.UTF8.GetString(packet.Serialize().Span); @@ -52,7 +52,7 @@ void Should_Serialize_Disconnect_Packet_With_Namespace() [Fact] void Should_Reject_A_Payload_On_A_Disconnect_Packet() { - var packet = Packet.DisconnectPacket; + var packet = PacketBuilder.Disconnect; Assert.Throws(() => packet.AddItem("World")); } diff --git a/tests/SocketIO.Client.Tests/Packets/EventPacketTests.cs b/tests/SocketIO.Client.Tests/Packets/EventPacketTests.cs index 2068e2a..7558dd6 100644 --- a/tests/SocketIO.Client.Tests/Packets/EventPacketTests.cs +++ b/tests/SocketIO.Client.Tests/Packets/EventPacketTests.cs @@ -15,7 +15,7 @@ public class EventPacketTests [Fact] void Should_Create_Event_Packet() { - var packet = new Packet(PacketType.Event); + var packet = new PacketBuilder(PacketType.Event); Assert.Equal(PacketType.Event, packet.Type); Assert.Equal("/", packet.Namespace); @@ -27,7 +27,7 @@ void Should_Create_Event_Packet_With_Namespace() { var @namespace = "test"; - var packet = new Packet(PacketType.Event, @namespace); + var packet = new PacketBuilder(PacketType.Event, @namespace); Assert.Equal(PacketType.Event, packet.Type); Assert.Equal($"/{@namespace}", packet.Namespace); @@ -39,7 +39,7 @@ void Should_Create_Event_Packet_With_Namespace() [InlineData("/test")] void Should_Normalize_Namespace(string @namespace) { - var packet = new Packet(PacketType.Event, @namespace); + var packet = new PacketBuilder(PacketType.Event, @namespace); packet.AddItem("Hello!"); Assert.Equal("/test", packet.Namespace); @@ -49,7 +49,7 @@ void Should_Normalize_Namespace(string @namespace) [Fact(DisplayName = "A comma would end the namespace early, so one is refused")] void Should_Reject_A_Namespace_Containing_A_Comma() { - Assert.Throws(() => new Packet(PacketType.Event, "a,b")); + Assert.Throws(() => new PacketBuilder(PacketType.Event, "a,b")); } [Fact] @@ -57,7 +57,7 @@ void Should_Create_Event_Packet_With_Event_Name() { var eventName = "test"; - var packet = new Packet(PacketType.Event, null, eventName); + var packet = new PacketBuilder(PacketType.Event, null, eventName); Assert.Equal(PacketType.Event, packet.Type); Assert.Equal("/", packet.Namespace); @@ -73,7 +73,7 @@ void Should_Create_Event_Packet_With_Event_Name() [InlineData("removeListener")] void Should_Reject_A_Reserved_Event_Name(string eventName) { - Assert.Throws(() => new Packet(PacketType.Event, null, eventName)); + Assert.Throws(() => new PacketBuilder(PacketType.Event, null, eventName)); } [Fact] @@ -81,7 +81,7 @@ void Should_Create_Ack_Packet_With_Ack_Id() { var ackId = 42; - var packet = new Packet(PacketType.Ack, ackId, null, null); + var packet = new PacketBuilder(PacketType.Ack, ackId, null, null); Assert.Equal(PacketType.Ack, packet.Type); Assert.Equal(ackId, packet.AckId); @@ -92,7 +92,7 @@ void Should_Request_An_Acknowledgement_On_An_Event() { var ackId = 7; - var packet = new Packet(PacketType.Event, ackId, null, null); + var packet = new PacketBuilder(PacketType.Event, ackId, null, null); packet.AddItem("Hello!"); Assert.Equal(ackId, packet.AckId); @@ -102,7 +102,7 @@ void Should_Request_An_Acknowledgement_On_An_Event() [Fact(DisplayName = "An acknowledgement does not carry an event name")] void Should_Reject_An_Event_Name_On_An_Acknowledgement() { - Assert.Throws(() => new Packet(PacketType.Ack, 1, null, "test")); + Assert.Throws(() => new PacketBuilder(PacketType.Ack, 1, null, "test")); } [Theory(DisplayName = "An acknowledgement has to name the event it answers")] @@ -110,7 +110,7 @@ void Should_Reject_An_Event_Name_On_An_Acknowledgement() [InlineData(PacketType.BinaryAck)] void Should_Reject_An_Acknowledgement_Without_An_Ack_Id(PacketType type) { - Assert.Throws(() => new Packet(type, null, null)); + Assert.Throws(() => new PacketBuilder(type, null, null)); } [Theory(DisplayName = "A sign would be read as the start of the payload")] @@ -118,7 +118,7 @@ void Should_Reject_An_Acknowledgement_Without_An_Ack_Id(PacketType type) [InlineData(int.MinValue)] void Should_Reject_A_Negative_Ack_Id(int ackId) { - Assert.Throws(() => new Packet(PacketType.Event, ackId, null, null)); + Assert.Throws(() => new PacketBuilder(PacketType.Event, ackId, null, null)); } [Theory(DisplayName = "Only the types that take part in one carry an ack id")] @@ -127,19 +127,19 @@ void Should_Reject_A_Negative_Ack_Id(int ackId) [InlineData(PacketType.ConnectError)] void Should_Reject_An_Ack_Id_On_A_Type_That_Cannot_Carry_One(PacketType type) { - Assert.Throws(() => new Packet(type, 1, null, null)); + Assert.Throws(() => new PacketBuilder(type, 1, null, null)); } [Fact] void Should_Reject_An_Unknown_Packet_Type() { - Assert.Throws(() => new Packet((PacketType)0x39)); + Assert.Throws(() => new PacketBuilder((PacketType)0x39)); } [Fact] void Should_Serialize_Plaintext_Event_Packet() { - var packet = new Packet(PacketType.Event); + var packet = new PacketBuilder(PacketType.Event); packet.AddItem("Hello!"); var encodedPacket = Encoding.UTF8.GetString(packet.Serialize().Span); @@ -150,7 +150,7 @@ void Should_Serialize_Plaintext_Event_Packet() [Fact] void Should_Reject_Binary_On_A_Plaintext_Event() { - var packet = new Packet(PacketType.Event); + var packet = new PacketBuilder(PacketType.Event); var invalidPayload = new ReadOnlyMemory(new byte[] { 1, 2, 3 }); Assert.Throws(() => packet.AddItem(invalidPayload)); @@ -162,7 +162,7 @@ void Should_Serialize_Plaintext_Event_With_Namespace() var @namespace = "test"; var expectedEncodedPacket = $"""2/{@namespace},["message","Hello!"]"""; - var packet = new Packet(PacketType.Event, @namespace); + var packet = new PacketBuilder(PacketType.Event, @namespace); packet.AddItem("Hello!"); Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); @@ -174,7 +174,7 @@ void Should_Serialize_Plaintext_Event_With_Event_Name() var eventName = "test"; var expectedEncodedPacket = $$"""2["{{eventName}}","Hello!"]"""; - var packet = new Packet(PacketType.Event, null, eventName); + var packet = new PacketBuilder(PacketType.Event, null, eventName); packet.AddItem("Hello!"); Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); @@ -189,7 +189,7 @@ void Should_Serialize_Plaintext_Ack_Packet() // is the response arguments alone. var expectedEncodedPacket = $$"""3{{ackId}}["Hello!"]"""; - var packet = new Packet(PacketType.Ack, ackId, null, null); + var packet = new PacketBuilder(PacketType.Ack, ackId, null, null); packet.AddItem("Hello!"); Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); @@ -198,7 +198,7 @@ void Should_Serialize_Plaintext_Ack_Packet() [Fact] void Should_Serialize_Json_Event_Packet() { - var packet = new Packet(PacketType.Event); + var packet = new PacketBuilder(PacketType.Event); packet.AddItem(new Foo { Value = "bar" }); var encodedPacket = Encoding.UTF8.GetString(packet.Serialize().Span); @@ -212,7 +212,7 @@ void Should_Serialize_Json_Event_With_Namespace() var @namespace = "test"; var expectedEncodedPacket = $$"""2/{{@namespace}},["message",{"Value":"bar"}]"""; - var packet = new Packet(PacketType.Event, @namespace); + var packet = new PacketBuilder(PacketType.Event, @namespace); packet.AddItem(new Foo { Value = "bar" }); Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); @@ -224,7 +224,7 @@ void Should_Serialize_Json_Event_With_Event_Name() var eventName = "test"; var expectedEncodedPacket = $$"""2["{{eventName}}",{"Value":"bar"}]"""; - var packet = new Packet(PacketType.Event, null, eventName); + var packet = new PacketBuilder(PacketType.Event, null, eventName); packet.AddItem(new Foo { Value = "bar" }); Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); @@ -236,7 +236,7 @@ void Should_Serialize_Json_Ack_Packet() var ackId = 42; var expectedEncodedPacket = $$"""3{{ackId}}[{"Value":"bar"}]"""; - var packet = new Packet(PacketType.Ack, ackId, null, null); + var packet = new PacketBuilder(PacketType.Ack, ackId, null, null); packet.AddItem(new Foo { Value = "bar" }); Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); @@ -245,7 +245,7 @@ void Should_Serialize_Json_Ack_Packet() [Fact(DisplayName = "The same packet encodes identically every time it is sent")] void Should_Serialize_The_Same_Packet_Repeatedly() { - var packet = new Packet(PacketType.Event); + var packet = new PacketBuilder(PacketType.Event); packet.AddItem("Hello!"); var first = Encoding.UTF8.GetString(packet.Serialize().Span); From 9dfe46a25c84d1c1cae09626a32ff511975185fb Mon Sep 17 00:00:00 2001 From: redouane Date: Tue, 8 Sep 2026 23:39:59 +0200 Subject: [PATCH 20/21] feat(socketio): reassemble a packet that spans several messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A binary packet is a header followed by exactly as many binary Engine.io messages as it announced. Parsing cannot own that: TryParse is a function of one buffer, and making it remember what came before would make a parse call depend on the order it was made in. Decoder holds the header back until the attachments have arrived. Its two errors — an attachment with no header waiting, a header arriving while one is still owed — are the pair socket.io's own decoder raises, and both mean the run of messages making up a packet has been broken into. They are raised rather than swallowed because a peer answers an undecodable packet by closing the transport. Only the attachment-side error can be exercised yet; everything through AddHeader waits on Packet.TryParse. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CsxPBM9F82FBXVQDV1jFmN --- .../Exceptions/PacketFormatException.cs | 19 ++++ src/SocketIO.Client/Packets/Decoder.cs | 96 +++++++++++++++++++ .../Packets/DecoderTests.cs | 26 +++++ 3 files changed, 141 insertions(+) create mode 100644 src/SocketIO.Client/Exceptions/PacketFormatException.cs create mode 100644 src/SocketIO.Client/Packets/Decoder.cs create mode 100644 tests/SocketIO.Client.Tests/Packets/DecoderTests.cs diff --git a/src/SocketIO.Client/Exceptions/PacketFormatException.cs b/src/SocketIO.Client/Exceptions/PacketFormatException.cs new file mode 100644 index 0000000..6a91a84 --- /dev/null +++ b/src/SocketIO.Client/Exceptions/PacketFormatException.cs @@ -0,0 +1,19 @@ +using System; + +namespace SocketIO.Client.Exceptions; + +/// +/// Thrown when the bytes that arrived are not a packet the protocol allows. +/// +/// +/// Fatal to the connection rather than to the packet: a server answers a packet it +/// cannot decode by closing the transport, so a client that carried on after one +/// would be talking to nobody. +/// +public class PacketFormatException : Exception +{ + public PacketFormatException(string message) + : base(message) + { + } +} \ No newline at end of file diff --git a/src/SocketIO.Client/Packets/Decoder.cs b/src/SocketIO.Client/Packets/Decoder.cs new file mode 100644 index 0000000..775bd3b --- /dev/null +++ b/src/SocketIO.Client/Packets/Decoder.cs @@ -0,0 +1,96 @@ +using System; + +using EngineIO.Client.Packets; + +using SocketIO.Client.Exceptions; + +using EnginePacket = EngineIO.Client.Packets.Packet; + +namespace SocketIO.Client.Packets; + +/// +/// Turns a stream of Engine.io messages into Socket.IO packets. +/// +/// +/// +/// A text packet is one Engine.io message and is done when it is parsed. A +/// binary one is a header followed by exactly as many binary messages as it +/// announced, so the decoder holds it back until they have all arrived. That +/// waiting is the whole reason this type exists rather than the work living in +/// , which stays a pure function of one buffer. +/// +/// +/// Both errors it raises are the ones the reference decoder raises, and they +/// mean the stream is no longer trustworthy: the run of messages that make up +/// a packet has been broken into. Neither is recoverable by skipping a packet. +/// +/// +internal sealed class Decoder +{ + /// + /// The header still owed attachments, if any. + /// + private Packet? _pending; + + /// + /// Whether a packet is part-way through arriving. + /// + public bool IsReconstructing => _pending is not null; + + /// + /// Take the next Engine.io message. + /// + /// An Engine.io message packet + /// + /// The packet, once it is whole, or null while one is still arriving. + /// + public Packet? Add(EnginePacket message) + { + return message.Format == PacketFormat.Binary + ? AddAttachment(message.Body) + : AddHeader(message.Body); + } + + private Packet? AddHeader(ReadOnlyMemory data) + { + if (_pending is not null) + { + throw new PacketFormatException( + "A packet header arrived while another was still waiting for its attachments."); + } + + if (!Packet.TryParse(data, out var packet)) + { + throw new PacketFormatException("The packet could not be decoded."); + } + + // A packet that announced no attachments is whole as soon as it is parsed, + // which is every packet that is not a binary one. + if (packet!.IsComplete) + { + return packet; + } + + _pending = packet; + return null; + } + + private Packet? AddAttachment(ReadOnlyMemory data) + { + if (_pending is null) + { + throw new PacketFormatException("A binary attachment arrived with no packet waiting for one."); + } + + _pending.Attach(data); + + if (!_pending.IsComplete) + { + return null; + } + + var completed = _pending; + _pending = null; + return completed; + } +} \ No newline at end of file diff --git a/tests/SocketIO.Client.Tests/Packets/DecoderTests.cs b/tests/SocketIO.Client.Tests/Packets/DecoderTests.cs new file mode 100644 index 0000000..9615c3e --- /dev/null +++ b/tests/SocketIO.Client.Tests/Packets/DecoderTests.cs @@ -0,0 +1,26 @@ +using SocketIO.Client.Exceptions; +using SocketIO.Client.Packets; + +using EnginePacket = EngineIO.Client.Packets.Packet; + +namespace SocketIO.Client.Tests.Packets; + +public class DecoderTests +{ + [Fact] + void Should_Not_Be_Reconstructing_Before_Anything_Arrives() + { + var decoder = new Decoder(); + + Assert.False(decoder.IsReconstructing); + } + + [Fact(DisplayName = "An attachment with no header before it means the stream is out of step")] + void Should_Reject_An_Attachment_With_No_Packet_Waiting() + { + var decoder = new Decoder(); + + Assert.Throws( + () => decoder.Add(EnginePacket.CreateBinaryPacket(new byte[] { 1, 2, 3 }))); + } +} \ No newline at end of file From 170b741706ccff603516a29a7de87a73e2aa05be Mon Sep 17 00:00:00 2001 From: redouane Date: Tue, 8 Sep 2026 23:43:10 +0200 Subject: [PATCH 21/21] feat(socketio): implement ListenAsync over the decoded stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ListenAsync threw. It now hands back a stream per namespace, fed by a single loop that drains Engine.ListenAsync through the Decoder. One loop rather than one per listener: the engine exposes a channel, so two listeners draining it would compete and each would see roughly half the packets. The queues are created on demand, which also means a listener that subscribed before connecting misses nothing. Route splits protocol concerns from consumer concerns the way PollAsync does for the heartbeat: CONNECT, DISCONNECT and CONNECT_ERROR belong to the client rather than the caller, and are marked for the namespace work that will own them. A packet for a namespace nobody listens to is dropped rather than buffered, since buffering it would grow without limit. A listener that subscribes after the stream has ended is completed rather than left waiting — found by the disposal test, which passed alone and failed under the parallel run where disposal won the race. Everything here is exercised for its lifecycle only. Nothing decodes until Packet.TryParse exists. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CsxPBM9F82FBXVQDV1jFmN --- src/SocketIO.Client/IO.cs | 155 ++++++++++++++++++++++++- tests/SocketIO.Client.Tests/IOTests.cs | 32 +++++ 2 files changed, 182 insertions(+), 5 deletions(-) diff --git a/src/SocketIO.Client/IO.cs b/src/SocketIO.Client/IO.cs index 0aedb47..1a88bc2 100644 --- a/src/SocketIO.Client/IO.cs +++ b/src/SocketIO.Client/IO.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Net.Http; using System.Threading; +using System.Threading.Channels; using System.Threading.Tasks; using EngineIO.Client; @@ -49,9 +51,47 @@ public sealed class IO : IAsyncDisposable /// private readonly SemaphoreSlim _sendLock = new(1, 1); + /// + /// One queue per namespace being listened to, created the first time somebody + /// asks for it. + /// + /// + /// A single loop drains the Engine.io stream and fans out from it. Letting each + /// listener drain that stream itself would not work: it is one channel, so two + /// listeners would compete for packets and each would see roughly half of them. + /// Creating the queue on demand also means a listener that subscribed before + /// connecting misses nothing. + /// + private readonly ConcurrentDictionary> _listeners = new(); + + /// + /// Ends the receive loop when the client is disposed. + /// + private readonly CancellationTokenSource _receiveCancellation = new(); + + private readonly ILogger? _logger; + + /// + /// The loop that turns Engine.io messages into packets, started on connect. + /// + private Task? _receiveTask; + + /// + /// Set once the receive loop has ended, before any queue is completed, so that + /// a listener arriving afterwards can see that it has missed the stream. + /// + private volatile bool _receiveEnded; + + /// + /// Why the receive loop ended, if it ended badly. Written before + /// , which publishes it. + /// + private Exception? _receiveError; + public IO(string baseAddress, string path = DefaultPath, ILoggerFactory? loggerFactory = null) { Path = path; + _logger = loggerFactory?.CreateLogger(); _client = new Engine(Configure(baseAddress, path), loggerFactory); } @@ -64,6 +104,7 @@ internal IO(HttpClient httpClient, string baseAddress, string path = DefaultPath ILoggerFactory? loggerFactory = null) { Path = path; + _logger = loggerFactory?.CreateLogger(); _client = new Engine(Configure(baseAddress, path), httpClient, loggerFactory: loggerFactory); } @@ -91,7 +132,18 @@ private static Action Configure(string baseAddress, string path) public async ValueTask DisposeAsync() { + // Stop receiving before the engine goes away, so the loop unwinds on its own + // token rather than on whatever the teardown happens to throw at it. + await _receiveCancellation.CancelAsync().ConfigureAwait(false); + + if (_receiveTask is not null) + { + await _receiveTask.ConfigureAwait(false); + } + await _client.DisposeAsync().ConfigureAwait(false); + + _receiveCancellation.Dispose(); _connectLock.Dispose(); _sendLock.Dispose(); } @@ -124,6 +176,13 @@ public async Task ConnectAsync(string? @namespace = default, CancellationToken c "The Engine.io connection could not be established.", _client.ConnectionError); } } + + // Restarted after a reconnection: the previous loop ended with the + // connection that fed it. + if (_receiveTask is null or { IsCompleted: true }) + { + _receiveTask = Task.Run(ReceiveAsync, CancellationToken.None); + } } finally { @@ -156,11 +215,97 @@ public async Task DisconnectAsync(string? @namespace = default, CancellationToke public IAsyncEnumerable ListenAsync( string? @namespace = default, CancellationToken cancellationToken = default) { - // TODO: decoding a Socket.IO packet from the Engine.io message stream, holding - // a binary header back until its attachments have arrived, and routing the - // result to the listener of the namespace it names. - throw new NotImplementedException( - "Receiving requires the Socket.IO packet parser, which is not implemented yet."); + var queue = _listeners.GetOrAdd( + PacketBuilder.NormalizeNamespace(@namespace), _ => Channel.CreateUnbounded()); + + // The stream may already have ended — the client disposed, the connection + // gone. A queue created after that is one nothing will ever complete, so its + // listener would wait for a packet that cannot arrive. + if (_receiveEnded) + { + queue.Writer.TryComplete(_receiveError); + } + + return queue.Reader.ReadAllAsync(cancellationToken); + } + + /// + /// Drain the Engine.io message stream, decode it, and hand each packet to the + /// namespace that is listening for it. + /// + private async Task ReceiveAsync() + { + var decoder = new Decoder(); + + try + { + await foreach (var message in _client.ListenAsync(_receiveCancellation.Token).ConfigureAwait(false)) + { + var packet = decoder.Add(message); + + if (packet is not null) + { + Route(packet); + } + } + + EndListeners(null); + } + catch (OperationCanceledException) + { + // Shutting down through DisposeAsync is not a failure. + EndListeners(null); + } + catch (Exception exception) + { + _logger?.LogError(exception, "The receive loop ended."); + EndListeners(exception); + } + } + + /// + /// Split protocol concerns from consumer concerns, the way + /// Engine.PollAsync does for the heartbeat. + /// + private void Route(Packet packet) + { + // TODO: Connect carries the namespace's own sid, which is what _namespaces is + // waiting for; Disconnect ends it, and ConnectError has to reach whoever asked + // to join rather than being dropped here. + if (packet.Type is PacketType.Connect or PacketType.Disconnect or PacketType.ConnectError) + { + _logger?.LogDebug("Namespace {Namespace} sent {Type}, which is not routed yet.", + packet.Namespace, packet.Type); + return; + } + + if (_listeners.TryGetValue(packet.Namespace, out var queue)) + { + // The channel is unbounded, so this never fails or blocks. + queue.Writer.TryWrite(packet); + return; + } + + // Buffering for a namespace nobody listens to would grow without limit. + _logger?.LogDebug("Dropped a {Type} packet for {Namespace}, which has no listener.", + packet.Type, packet.Namespace); + } + + /// + /// End every listener's enumeration, with the reason if there was one. + /// + private void EndListeners(Exception? exception) + { + // Published before anything is completed, so a listener subscribing alongside + // this either is completed by the loop below or completes itself. Both may + // happen; completing a queue twice is harmless. + _receiveError = exception; + _receiveEnded = true; + + foreach (var queue in _listeners.Values) + { + queue.Writer.TryComplete(exception); + } } /// diff --git a/tests/SocketIO.Client.Tests/IOTests.cs b/tests/SocketIO.Client.Tests/IOTests.cs index a2858e3..f5b3fa7 100644 --- a/tests/SocketIO.Client.Tests/IOTests.cs +++ b/tests/SocketIO.Client.Tests/IOTests.cs @@ -1,4 +1,5 @@ using SocketIO.Client.Exceptions; +using SocketIO.Client.Packets; namespace SocketIO.Client.Tests; @@ -150,6 +151,37 @@ await Task.WhenAll(Enumerable.Range(0, senders) } } + [Fact] + void ListenAsync_Should_Return_A_Stream_Rather_Than_Throwing() + { + var (io, _) = CreateClient(FakePollingServer.Handshake()); + + Assert.NotNull(io.ListenAsync()); + Assert.NotNull(io.ListenAsync("admin")); + } + + [Fact(DisplayName = "Disposing the client ends every listener's enumeration")] + async Task ListenAsync_Should_End_When_The_Client_Is_Disposed() + { + var (io, _) = CreateClient(FakePollingServer.Handshake()); + await io.ConnectAsync(); + + var received = new List(); + var listening = Task.Run(async () => + { + await foreach (var packet in io.ListenAsync()) + { + received.Add(packet); + } + }); + + await io.DisposeAsync(); + + // Without the listeners being completed this would never return. + await listening.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Empty(received); + } + private static (IO Client, FakePollingServer Server) CreateClient(params byte[][] pollResponses) { return CreateClient(TimeSpan.Zero, pollResponses);