diff --git a/Assets/Tests/Editor/UnityCliLoopBridgeResponseWriterTests.cs b/Assets/Tests/Editor/UnityCliLoopBridgeResponseWriterTests.cs new file mode 100644 index 0000000000..7f9378c176 --- /dev/null +++ b/Assets/Tests/Editor/UnityCliLoopBridgeResponseWriterTests.cs @@ -0,0 +1,49 @@ +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Infrastructure; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Tests the byte-level Content-Length framing emitted by the project IPC server. + /// + public sealed class UnityCliLoopBridgeResponseWriterTests + { + /// + /// Verifies an empty JSON response does not emit a frame. + /// + [Test] + public void CreateContentLengthFrame_WhenJsonIsEmpty_ReturnsEmptyFrame() + { + string frame = UnityCliLoopBridgeResponseWriter.CreateContentLengthFrame(string.Empty); + + Assert.That(frame, Is.Empty); + } + + /// + /// Verifies ASCII JSON is framed with its exact UTF-8 byte length and header separators. + /// + [Test] + public void CreateContentLengthFrame_WhenJsonIsAscii_ReturnsExactFrame() + { + const string Json = "{\"id\":1}"; + + string frame = UnityCliLoopBridgeResponseWriter.CreateContentLengthFrame(Json); + + Assert.That(frame, Is.EqualTo("Content-Length: 8\r\n\r\n{\"id\":1}")); + } + + /// + /// Verifies multibyte JSON uses UTF-8 byte length rather than UTF-16 character count. + /// + [Test] + public void CreateContentLengthFrame_WhenJsonContainsMultibyteText_UsesUtf8ByteLength() + { + const string Json = "{\"message\":\"あ\"}"; + + string frame = UnityCliLoopBridgeResponseWriter.CreateContentLengthFrame(Json); + + Assert.That(frame, Is.EqualTo("Content-Length: 17\r\n\r\n{\"message\":\"あ\"}")); + } + } +} diff --git a/Assets/Tests/Editor/UnityCliLoopBridgeResponseWriterTests.cs.meta b/Assets/Tests/Editor/UnityCliLoopBridgeResponseWriterTests.cs.meta new file mode 100644 index 0000000000..d0f885971c --- /dev/null +++ b/Assets/Tests/Editor/UnityCliLoopBridgeResponseWriterTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0b07d99dd8364e8f849f35d650a5a27c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeResponseWriter.cs b/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeResponseWriter.cs new file mode 100644 index 0000000000..cc89c71ff0 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeResponseWriter.cs @@ -0,0 +1,72 @@ +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Frames and serializes JSON responses written to project IPC client streams. + /// + internal static class UnityCliLoopBridgeResponseWriter + { + /// + /// Creates a Content-Length framed message for JSON-RPC 2.0 communication. + /// + /// The JSON content to frame + /// The framed message with Content-Length header + internal static string CreateContentLengthFrame(string jsonContent) + { + if (string.IsNullOrEmpty(jsonContent)) + { + return string.Empty; + } + + // Calculate content length in bytes (UTF-8 encoding) + int contentLength = Encoding.UTF8.GetByteCount(jsonContent); + + // Create the framed message: Content-Length: \r\n\r\n + return $"Content-Length: {contentLength}\r\n\r\n{jsonContent}"; + } + + private static async Task WriteJsonResponseAsync( + Stream stream, + string responseJson, + CancellationToken ct) + { + if (string.IsNullOrEmpty(responseJson)) + { + return; + } + + if (!stream.CanWrite || ct.IsCancellationRequested) + { + return; + } + + string framedResponse = CreateContentLengthFrame(responseJson); + byte[] responseData = Encoding.UTF8.GetBytes(framedResponse); + await stream.WriteAsync(responseData, 0, responseData.Length, ct); + } + + internal static async Task WriteJsonResponseLockedAsync( + Stream stream, + SemaphoreSlim streamWriteLock, + string responseJson, + CancellationToken ct) + { + // Why: heartbeat frames are written from a background timer while the final + // response is written by the request task; interleaved writes would corrupt + // Content-Length framing, so all frame writes share one lock per connection. + await streamWriteLock.WaitAsync(ct); + try + { + await WriteJsonResponseAsync(stream, responseJson, ct); + } + finally + { + streamWriteLock.Release(); + } + } + } +} diff --git a/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeResponseWriter.cs.meta b/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeResponseWriter.cs.meta new file mode 100644 index 0000000000..4a2c95aed9 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeResponseWriter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 90ec8a0d5ab84fdba1fbc98167d47183 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServer.cs b/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServer.cs index a3ad421162..e0575d39c3 100644 --- a/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServer.cs +++ b/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServer.cs @@ -4,7 +4,6 @@ using System.IO; using System.Linq; using System.Net.Sockets; -using System.Text; using System.Threading; using System.Threading.Tasks; using UnityEngine; @@ -575,45 +574,6 @@ private async Task HandleClientAsync(BridgeClientConnection client, Cancellation } } - /// - /// Creates a Content-Length framed message for JSON-RPC 2.0 communication. - /// - /// The JSON content to frame - /// The framed message with Content-Length header - private string CreateContentLengthFrame(string jsonContent) - { - if (string.IsNullOrEmpty(jsonContent)) - { - return string.Empty; - } - - // Calculate content length in bytes (UTF-8 encoding) - int contentLength = Encoding.UTF8.GetByteCount(jsonContent); - - // Create the framed message: Content-Length: \r\n\r\n - return $"Content-Length: {contentLength}\r\n\r\n{jsonContent}"; - } - - private async Task WriteJsonResponseAsync( - Stream stream, - string responseJson, - CancellationToken ct) - { - if (string.IsNullOrEmpty(responseJson)) - { - return; - } - - if (!stream.CanWrite || ct.IsCancellationRequested) - { - return; - } - - string framedResponse = CreateContentLengthFrame(responseJson); - byte[] responseData = Encoding.UTF8.GetBytes(framedResponse); - await stream.WriteAsync(responseData, 0, responseData.Length, ct); - } - private async Task ProcessRequestFrameAsync( BridgeClientConnection client, Stream stream, @@ -634,7 +594,7 @@ private async Task ProcessRequestFrameAsync( requestCancellationTokenSource.Token, async (responseJsonValue, cancelOnClientDisconnect, createHeartbeatJson) => { - await WriteJsonResponseLockedAsync( + await UnityCliLoopBridgeResponseWriter.WriteJsonResponseLockedAsync( stream, streamWriteLock, responseJsonValue, serverCancellationToken); if (createHeartbeatJson != null) @@ -647,7 +607,7 @@ await WriteJsonResponseLockedAsync( CancellationToken heartbeatToken = heartbeatCancellationSource.Token; heartbeatTask = _heartbeatService.SendHeartbeatsAsync( createHeartbeatJson, - heartbeatJson => WriteJsonResponseLockedAsync( + heartbeatJson => UnityCliLoopBridgeResponseWriter.WriteJsonResponseLockedAsync( stream, streamWriteLock, heartbeatJson, heartbeatToken), TimeSpan.FromSeconds(UnityCliLoopServerConfig.HEARTBEAT_INTERVAL_SECONDS), heartbeatToken); @@ -669,7 +629,8 @@ await WriteJsonResponseLockedAsync( await _heartbeatService.StopHeartbeatsAsync(heartbeatTask, heartbeatCancellationSource); heartbeatTask = null; - await WriteJsonResponseLockedAsync(stream, streamWriteLock, responseJson, serverCancellationToken); + await UnityCliLoopBridgeResponseWriter.WriteJsonResponseLockedAsync( + stream, streamWriteLock, responseJson, serverCancellationToken); } finally { @@ -682,26 +643,6 @@ await _clientDisconnectMonitor.StopClientDisconnectMonitorAsync( } } - private async Task WriteJsonResponseLockedAsync( - Stream stream, - SemaphoreSlim streamWriteLock, - string responseJson, - CancellationToken ct) - { - // Why: heartbeat frames are written from a background timer while the final - // response is written by the request task; interleaved writes would corrupt - // Content-Length framing, so all frame writes share one lock per connection. - await streamWriteLock.WaitAsync(ct); - try - { - await WriteJsonResponseAsync(stream, responseJson, ct); - } - finally - { - streamWriteLock.Release(); - } - } - /// /// Determines if the given exception represents a normal client disconnection. ///