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.
///