From 1923431e1bc8fdd31f2ab455e9246201e1858f1d Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 9 Jul 2026 07:27:10 +0900 Subject: [PATCH] Extract project IPC response writer Isolate Content-Length framing and serialized stream writes so the bridge server keeps request handshake orchestration separate from byte emission. Preserve the accepted, heartbeat, and final response call order while fixing the frame contract with UTF-8 byte-length tests. --- .../UnityCliLoopBridgeResponseWriterTests.cs | 49 +++++++++++++ ...tyCliLoopBridgeResponseWriterTests.cs.meta | 11 +++ .../UnityCliLoopBridgeResponseWriter.cs | 72 +++++++++++++++++++ .../UnityCliLoopBridgeResponseWriter.cs.meta | 11 +++ .../UnityCliLoopBridgeServer.cs | 67 ++--------------- 5 files changed, 147 insertions(+), 63 deletions(-) create mode 100644 Assets/Tests/Editor/UnityCliLoopBridgeResponseWriterTests.cs create mode 100644 Assets/Tests/Editor/UnityCliLoopBridgeResponseWriterTests.cs.meta create mode 100644 Packages/src/Editor/Infrastructure/UnityCliLoopBridgeResponseWriter.cs create mode 100644 Packages/src/Editor/Infrastructure/UnityCliLoopBridgeResponseWriter.cs.meta 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. ///