From 701612c8599b024776f343e3af5333c51e784b13 Mon Sep 17 00:00:00 2001 From: Andrew Stanton-Nurse Date: Wed, 22 Feb 2017 09:30:31 -0800 Subject: [PATCH] fix #204 by implementing SSE formatter (#210) --- .../BinaryMessageFormatter.cs | 2 +- .../{ => Formatters}/MessageFormatter.cs | 5 +- .../ServerSentEventsMessageFormatter.cs | 186 ++++++++++++++++++ .../{ => Formatters}/TextMessageFormatter.cs | 2 +- .../MessageFormat.cs | 6 +- ...Microsoft.AspNetCore.Sockets.Common.csproj | 1 + .../HubConnectionTests.cs | 13 +- .../BinaryMessageFormatterTests.cs | 24 ++- .../ServerSentEventsMessageFormatterTests.cs | 86 ++++++++ .../TextMessageFormatterTests.cs | 24 ++- .../MessageTestUtils.cs | 5 +- 11 files changed, 337 insertions(+), 17 deletions(-) rename src/Microsoft.AspNetCore.Sockets.Common/{ => Formatters}/BinaryMessageFormatter.cs (98%) rename src/Microsoft.AspNetCore.Sockets.Common/{ => Formatters}/MessageFormatter.cs (92%) create mode 100644 src/Microsoft.AspNetCore.Sockets.Common/Formatters/ServerSentEventsMessageFormatter.cs rename src/Microsoft.AspNetCore.Sockets.Common/{ => Formatters}/TextMessageFormatter.cs (99%) rename test/Microsoft.AspNetCore.Sockets.Common.Tests/{ => Formatters}/BinaryMessageFormatterTests.cs (91%) create mode 100644 test/Microsoft.AspNetCore.Sockets.Common.Tests/Formatters/ServerSentEventsMessageFormatterTests.cs rename test/Microsoft.AspNetCore.Sockets.Common.Tests/{ => Formatters}/TextMessageFormatterTests.cs (88%) diff --git a/src/Microsoft.AspNetCore.Sockets.Common/BinaryMessageFormatter.cs b/src/Microsoft.AspNetCore.Sockets.Common/Formatters/BinaryMessageFormatter.cs similarity index 98% rename from src/Microsoft.AspNetCore.Sockets.Common/BinaryMessageFormatter.cs rename to src/Microsoft.AspNetCore.Sockets.Common/Formatters/BinaryMessageFormatter.cs index 0e36412470..4150f84005 100644 --- a/src/Microsoft.AspNetCore.Sockets.Common/BinaryMessageFormatter.cs +++ b/src/Microsoft.AspNetCore.Sockets.Common/Formatters/BinaryMessageFormatter.cs @@ -5,7 +5,7 @@ using System; using System.Binary; using System.IO.Pipelines; -namespace Microsoft.AspNetCore.Sockets +namespace Microsoft.AspNetCore.Sockets.Formatters { internal static class BinaryMessageFormatter { diff --git a/src/Microsoft.AspNetCore.Sockets.Common/MessageFormatter.cs b/src/Microsoft.AspNetCore.Sockets.Common/Formatters/MessageFormatter.cs similarity index 92% rename from src/Microsoft.AspNetCore.Sockets.Common/MessageFormatter.cs rename to src/Microsoft.AspNetCore.Sockets.Common/Formatters/MessageFormatter.cs index a4850294a4..cb75eafef6 100644 --- a/src/Microsoft.AspNetCore.Sockets.Common/MessageFormatter.cs +++ b/src/Microsoft.AspNetCore.Sockets.Common/Formatters/MessageFormatter.cs @@ -1,9 +1,9 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -namespace Microsoft.AspNetCore.Sockets +namespace Microsoft.AspNetCore.Sockets.Formatters { public static class MessageFormatter { @@ -16,6 +16,7 @@ namespace Microsoft.AspNetCore.Sockets // giving it to us. Hence we throw, instead of returning false. throw new InvalidOperationException("Cannot format message where endOfMessage is false using this format"); } + return format == MessageFormat.Text ? TextMessageFormatter.TryFormatMessage(message, buffer, out bytesWritten) : BinaryMessageFormatter.TryFormatMessage(message, buffer, out bytesWritten); diff --git a/src/Microsoft.AspNetCore.Sockets.Common/Formatters/ServerSentEventsMessageFormatter.cs b/src/Microsoft.AspNetCore.Sockets.Common/Formatters/ServerSentEventsMessageFormatter.cs new file mode 100644 index 0000000000..f572ff905e --- /dev/null +++ b/src/Microsoft.AspNetCore.Sockets.Common/Formatters/ServerSentEventsMessageFormatter.cs @@ -0,0 +1,186 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO.Pipelines; +using System.Text; + +namespace Microsoft.AspNetCore.Sockets.Formatters +{ + public static class ServerSentEventsMessageFormatter + { + private static readonly Span DataPrefix = new byte[] { (byte)'d', (byte)'a', (byte)'t', (byte)'a', (byte)':', (byte)' ' }; + private static readonly Span Newline = new byte[] { (byte)'\r', (byte)'\n' }; + + private const byte LineFeed = (byte)'\n'; + private const byte TextTypeFlag = (byte)'T'; + private const byte BinaryTypeFlag = (byte)'B'; + private const byte CloseTypeFlag = (byte)'C'; + private const byte ErrorTypeFlag = (byte)'E'; + + public static bool TryFormatMessage(Message message, Span buffer, out int bytesWritten) + { + if (!message.EndOfMessage) + { + // This is a truely exceptional condition since we EXPECT callers to have already + // buffered incomplete messages and synthesized the correct, complete message before + // giving it to us. Hence we throw, instead of returning false. + throw new InvalidOperationException("Cannot format message where endOfMessage is false using this format"); + } + + // Need at least: Length of 'data: ', one character type, one \r\n, and the trailing \r\n + if (buffer.Length < DataPrefix.Length + 1 + Newline.Length + Newline.Length) + { + bytesWritten = 0; + return false; + } + DataPrefix.CopyTo(buffer); + buffer = buffer.Slice(DataPrefix.Length); + if (!TryFormatType(buffer, message.Type)) + { + bytesWritten = 0; + return false; + } + buffer = buffer.Slice(1); + + Newline.CopyTo(buffer); + buffer = buffer.Slice(Newline.Length); + + // Write the payload + if (!TryFormatPayload(message.Payload.Buffer, message.Type, buffer, out var writtenForPayload)) + { + bytesWritten = 0; + return false; + } + buffer = buffer.Slice(writtenForPayload); + + if (buffer.Length < Newline.Length) + { + bytesWritten = 0; + return false; + } + Newline.CopyTo(buffer); + + bytesWritten = DataPrefix.Length + Newline.Length + 1 + writtenForPayload + Newline.Length; + return true; + } + + private static bool TryFormatPayload(ReadableBuffer payload, MessageType type, Span buffer, out int bytesWritten) + { + // Short-cut for empty payload + if (payload.Length == 0) + { + bytesWritten = 0; + return true; + } + + var writtenSoFar = 0; + if (type == MessageType.Binary) + { + // TODO: We're going to need to fix this as part of https://github.com/aspnet/SignalR/issues/192 + var message = Convert.ToBase64String(payload.ToArray()); + var encodedSize = DataPrefix.Length + Encoding.UTF8.GetByteCount(message) + Newline.Length; + if (buffer.Length < encodedSize) + { + bytesWritten = 0; + return false; + } + DataPrefix.CopyTo(buffer); + buffer = buffer.Slice(DataPrefix.Length); + + var array = Encoding.UTF8.GetBytes(message); + array.CopyTo(buffer); + buffer = buffer.Slice(array.Length); + + Newline.CopyTo(buffer); + writtenSoFar += encodedSize; + buffer.Slice(Newline.Length); + } + else + { + while (true) + { + // Seek to the end of buffer or newline + var sliced = payload.TrySliceTo(LineFeed, out var slice, out var cursor); + + if (!TryFormatLine(sliced ? slice : payload, buffer, out var writtenByLine)) + { + bytesWritten = 0; + return false; + } + buffer = buffer.Slice(writtenByLine); + writtenSoFar += writtenByLine; + + if (sliced) + { + payload = payload.Slice(payload.Move(cursor, 1)); + } + else + { + break; + } + } + } + + bytesWritten = writtenSoFar; + return true; + } + + private static bool TryFormatLine(ReadableBuffer slice, Span buffer, out int bytesWritten) + { + // We're going to write the whole thing. HOWEVER, if the last byte is a '\r', we want to truncate it + // because it was the '\r' in a '\r\n' newline sequence + // This won't require an additional byte in the buffer because after this line we have to write a newline sequence anyway. + var writtenSoFar = 0; + if (buffer.Length < DataPrefix.Length + slice.Length) + { + bytesWritten = 0; + return false; + } + DataPrefix.CopyTo(buffer); + writtenSoFar += DataPrefix.Length; + buffer = buffer.Slice(DataPrefix.Length); + + slice.CopyTo(buffer); + var sliceTo = slice.Length; + if (sliceTo > 0 && buffer[sliceTo - 1] == '\r') + { + sliceTo -= 1; + } + writtenSoFar += sliceTo; + buffer = buffer.Slice(sliceTo); + + if (buffer.Length < Newline.Length) + { + bytesWritten = 0; + return false; + } + writtenSoFar += Newline.Length; + Newline.CopyTo(buffer); + + bytesWritten = writtenSoFar; + return true; + } + + private static bool TryFormatType(Span buffer, MessageType type) + { + switch (type) + { + case MessageType.Text: + buffer[0] = TextTypeFlag; + return true; + case MessageType.Binary: + buffer[0] = BinaryTypeFlag; + return true; + case MessageType.Close: + buffer[0] = CloseTypeFlag; + return true; + case MessageType.Error: + buffer[0] = ErrorTypeFlag; + return true; + default: + return false; + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Sockets.Common/TextMessageFormatter.cs b/src/Microsoft.AspNetCore.Sockets.Common/Formatters/TextMessageFormatter.cs similarity index 99% rename from src/Microsoft.AspNetCore.Sockets.Common/TextMessageFormatter.cs rename to src/Microsoft.AspNetCore.Sockets.Common/Formatters/TextMessageFormatter.cs index c401b3c845..6c324d6f1b 100644 --- a/src/Microsoft.AspNetCore.Sockets.Common/TextMessageFormatter.cs +++ b/src/Microsoft.AspNetCore.Sockets.Common/Formatters/TextMessageFormatter.cs @@ -5,7 +5,7 @@ using System; using System.IO.Pipelines; using System.Text; -namespace Microsoft.AspNetCore.Sockets +namespace Microsoft.AspNetCore.Sockets.Formatters { internal static class TextMessageFormatter { diff --git a/src/Microsoft.AspNetCore.Sockets.Common/MessageFormat.cs b/src/Microsoft.AspNetCore.Sockets.Common/MessageFormat.cs index 6c21571c36..9840e8f108 100644 --- a/src/Microsoft.AspNetCore.Sockets.Common/MessageFormat.cs +++ b/src/Microsoft.AspNetCore.Sockets.Common/MessageFormat.cs @@ -1,10 +1,6 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System; -using System.Collections.Generic; -using System.Text; - namespace Microsoft.AspNetCore.Sockets { public enum MessageFormat diff --git a/src/Microsoft.AspNetCore.Sockets.Common/Microsoft.AspNetCore.Sockets.Common.csproj b/src/Microsoft.AspNetCore.Sockets.Common/Microsoft.AspNetCore.Sockets.Common.csproj index a0b5414476..9da735ef4f 100644 --- a/src/Microsoft.AspNetCore.Sockets.Common/Microsoft.AspNetCore.Sockets.Common.csproj +++ b/src/Microsoft.AspNetCore.Sockets.Common/Microsoft.AspNetCore.Sockets.Common.csproj @@ -8,6 +8,7 @@ $(NoWarn);CS1591 true aspnetcore;signalr + Microsoft.AspNetCore.Sockets diff --git a/test/Microsoft.AspNetCore.Sockets.Client.Tests/HubConnectionTests.cs b/test/Microsoft.AspNetCore.Sockets.Client.Tests/HubConnectionTests.cs index 0e385da6db..e8be5baf4c 100644 --- a/test/Microsoft.AspNetCore.Sockets.Client.Tests/HubConnectionTests.cs +++ b/test/Microsoft.AspNetCore.Sockets.Client.Tests/HubConnectionTests.cs @@ -1,13 +1,16 @@ -using Microsoft.AspNetCore.SignalR.Tests.Common; -using Microsoft.AspNetCore.Sockets.Client; -using Microsoft.Extensions.Logging; -using Moq; -using Moq.Protected; +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + using System; using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Tests.Common; +using Microsoft.AspNetCore.Sockets.Client; +using Microsoft.Extensions.Logging; +using Moq; +using Moq.Protected; using Xunit; namespace Microsoft.AspNetCore.SignalR.Client.Tests diff --git a/test/Microsoft.AspNetCore.Sockets.Common.Tests/BinaryMessageFormatterTests.cs b/test/Microsoft.AspNetCore.Sockets.Common.Tests/Formatters/BinaryMessageFormatterTests.cs similarity index 91% rename from test/Microsoft.AspNetCore.Sockets.Common.Tests/BinaryMessageFormatterTests.cs rename to test/Microsoft.AspNetCore.Sockets.Common.Tests/Formatters/BinaryMessageFormatterTests.cs index b3f888a8b9..8af703d686 100644 --- a/test/Microsoft.AspNetCore.Sockets.Common.Tests/BinaryMessageFormatterTests.cs +++ b/test/Microsoft.AspNetCore.Sockets.Common.Tests/Formatters/BinaryMessageFormatterTests.cs @@ -4,9 +4,10 @@ using System; using System.Collections.Generic; using System.IO.Pipelines; +using Microsoft.AspNetCore.Sockets.Tests; using Xunit; -namespace Microsoft.AspNetCore.Sockets.Tests +namespace Microsoft.AspNetCore.Sockets.Formatters.Tests { public partial class BinaryMessageFormatterTests { @@ -167,5 +168,26 @@ namespace Microsoft.AspNetCore.Sockets.Tests Assert.False(MessageFormatter.TryParseMessage(encoded, MessageFormat.Binary, out var message, out var consumed)); Assert.Equal(0, consumed); } + + [Fact] + public void InsufficientWriteBufferSpace() + { + const int ExpectedSize = 13; + var message = MessageTestUtils.CreateMessage("Test", MessageType.Text); + + byte[] buffer; + int bufferSize; + int written; + for (bufferSize = 0; bufferSize < 13; bufferSize++) + { + buffer = new byte[bufferSize]; + Assert.False(MessageFormatter.TryFormatMessage(message, buffer, MessageFormat.Binary, out written)); + Assert.Equal(0, written); + } + + buffer = new byte[bufferSize]; + Assert.True(MessageFormatter.TryFormatMessage(message, buffer, MessageFormat.Binary, out written)); + Assert.Equal(ExpectedSize, written); + } } } diff --git a/test/Microsoft.AspNetCore.Sockets.Common.Tests/Formatters/ServerSentEventsMessageFormatterTests.cs b/test/Microsoft.AspNetCore.Sockets.Common.Tests/Formatters/ServerSentEventsMessageFormatterTests.cs new file mode 100644 index 0000000000..05cbd993ba --- /dev/null +++ b/test/Microsoft.AspNetCore.Sockets.Common.Tests/Formatters/ServerSentEventsMessageFormatterTests.cs @@ -0,0 +1,86 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO.Pipelines; +using System.Text; +using Microsoft.AspNetCore.Sockets.Tests; +using Xunit; + +namespace Microsoft.AspNetCore.Sockets.Formatters.Tests +{ + public class ServerSentEventsMessageFormatterTests + { + [Fact] + public void InsufficientWriteBufferSpace() + { + const int ExpectedSize = 23; + var message = MessageTestUtils.CreateMessage("Test", MessageType.Text); + + byte[] buffer; + int bufferSize; + int written; + for (bufferSize = 0; bufferSize < 23; bufferSize++) + { + buffer = new byte[bufferSize]; + Assert.False(ServerSentEventsMessageFormatter.TryFormatMessage(message, buffer, out written)); + Assert.Equal(0, written); + } + + buffer = new byte[bufferSize]; + Assert.True(ServerSentEventsMessageFormatter.TryFormatMessage(message, buffer, out written)); + Assert.Equal(ExpectedSize, written); + } + + [Fact] + public void WriteInvalidMessages() + { + var message = new Message(ReadableBuffer.Create(new byte[0]).Preserve(), MessageType.Binary, endOfMessage: false); + var ex = Assert.Throws(() => + ServerSentEventsMessageFormatter.TryFormatMessage(message, Span.Empty, out var written)); + Assert.Equal("Cannot format message where endOfMessage is false using this format", ex.Message); + } + + [Theory] + [InlineData("data: T\r\n\r\n", MessageType.Text, "")] + [InlineData("data: T\r\ndata: Hello, World\r\n\r\n", MessageType.Text, "Hello, World")] + [InlineData("data: T\r\ndata: Hello\r\ndata: World\r\n\r\n", MessageType.Text, "Hello\r\nWorld")] + [InlineData("data: T\r\ndata: Hello\r\ndata: World\r\n\r\n", MessageType.Text, "Hello\nWorld")] + [InlineData("data: T\r\ndata: Hello\r\ndata: \r\n\r\n", MessageType.Text, "Hello\n")] + [InlineData("data: T\r\ndata: Hello\r\ndata: \r\n\r\n", MessageType.Text, "Hello\r\n")] + [InlineData("data: C\r\n\r\n", MessageType.Close, "")] + [InlineData("data: C\r\ndata: Hello, World\r\n\r\n", MessageType.Close, "Hello, World")] + [InlineData("data: C\r\ndata: Hello\r\ndata: World\r\n\r\n", MessageType.Close, "Hello\r\nWorld")] + [InlineData("data: C\r\ndata: Hello\r\ndata: World\r\n\r\n", MessageType.Close, "Hello\nWorld")] + [InlineData("data: C\r\ndata: Hello\r\ndata: \r\n\r\n", MessageType.Close, "Hello\n")] + [InlineData("data: C\r\ndata: Hello\r\ndata: \r\n\r\n", MessageType.Close, "Hello\r\n")] + [InlineData("data: E\r\n\r\n", MessageType.Error, "")] + [InlineData("data: E\r\ndata: Hello, World\r\n\r\n", MessageType.Error, "Hello, World")] + [InlineData("data: E\r\ndata: Hello\r\ndata: World\r\n\r\n", MessageType.Error, "Hello\r\nWorld")] + [InlineData("data: E\r\ndata: Hello\r\ndata: World\r\n\r\n", MessageType.Error, "Hello\nWorld")] + [InlineData("data: E\r\ndata: Hello\r\ndata: \r\n\r\n", MessageType.Error, "Hello\n")] + [InlineData("data: E\r\ndata: Hello\r\ndata: \r\n\r\n", MessageType.Error, "Hello\r\n")] + public void WriteTextMessage(string encoded, MessageType messageType, string payload) + { + var message = MessageTestUtils.CreateMessage(payload, messageType); + + var buffer = new byte[256]; + Assert.True(ServerSentEventsMessageFormatter.TryFormatMessage(message, buffer, out var written)); + + Assert.Equal(encoded, Encoding.UTF8.GetString(buffer, 0, written)); + } + + [Theory] + [InlineData("data: B\r\n\r\n", new byte[0])] + [InlineData("data: B\r\ndata: q83v\r\n\r\n", new byte[] { 0xAB, 0xCD, 0xEF })] + public void WriteBinaryMessage(string encoded, byte[] payload) + { + var message = MessageTestUtils.CreateMessage(payload); + + var buffer = new byte[256]; + Assert.True(ServerSentEventsMessageFormatter.TryFormatMessage(message, buffer, out var written)); + + Assert.Equal(encoded, Encoding.UTF8.GetString(buffer, 0, written)); + } + } +} diff --git a/test/Microsoft.AspNetCore.Sockets.Common.Tests/TextMessageFormatterTests.cs b/test/Microsoft.AspNetCore.Sockets.Common.Tests/Formatters/TextMessageFormatterTests.cs similarity index 88% rename from test/Microsoft.AspNetCore.Sockets.Common.Tests/TextMessageFormatterTests.cs rename to test/Microsoft.AspNetCore.Sockets.Common.Tests/Formatters/TextMessageFormatterTests.cs index ffb21dd8dd..a343cca1e5 100644 --- a/test/Microsoft.AspNetCore.Sockets.Common.Tests/TextMessageFormatterTests.cs +++ b/test/Microsoft.AspNetCore.Sockets.Common.Tests/Formatters/TextMessageFormatterTests.cs @@ -5,9 +5,10 @@ using System; using System.Collections.Generic; using System.IO.Pipelines; using System.Text; +using Microsoft.AspNetCore.Sockets.Tests; using Xunit; -namespace Microsoft.AspNetCore.Sockets.Tests +namespace Microsoft.AspNetCore.Sockets.Formatters.Tests { public class TextMessageFormatterTests { @@ -152,5 +153,26 @@ namespace Microsoft.AspNetCore.Sockets.Tests Assert.False(MessageFormatter.TryParseMessage(buffer, MessageFormat.Text, out var message, out var consumed)); Assert.Equal(0, consumed); } + + [Fact] + public void InsufficientWriteBufferSpace() + { + const int ExpectedSize = 9; + var message = MessageTestUtils.CreateMessage("Test", MessageType.Text); + + byte[] buffer; + int bufferSize; + int written; + for (bufferSize = 0; bufferSize < 9; bufferSize++) + { + buffer = new byte[bufferSize]; + Assert.False(MessageFormatter.TryFormatMessage(message, buffer, MessageFormat.Text, out written)); + Assert.Equal(0, written); + } + + buffer = new byte[bufferSize]; + Assert.True(MessageFormatter.TryFormatMessage(message, buffer, MessageFormat.Text, out written)); + Assert.Equal(ExpectedSize, written); + } } } diff --git a/test/Microsoft.AspNetCore.Sockets.Common.Tests/MessageTestUtils.cs b/test/Microsoft.AspNetCore.Sockets.Common.Tests/MessageTestUtils.cs index 986af40f7f..35cc394e47 100644 --- a/test/Microsoft.AspNetCore.Sockets.Common.Tests/MessageTestUtils.cs +++ b/test/Microsoft.AspNetCore.Sockets.Common.Tests/MessageTestUtils.cs @@ -1,4 +1,7 @@ -using System.IO.Pipelines; +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO.Pipelines; using System.Text; using Xunit;