Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions Assets/Tests/Editor/UnityCliLoopBridgeResponseWriterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using NUnit.Framework;

using io.github.hatayama.UnityCliLoop.Infrastructure;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
/// <summary>
/// Tests the byte-level Content-Length framing emitted by the project IPC server.
/// </summary>
public sealed class UnityCliLoopBridgeResponseWriterTests
{
/// <summary>
/// Verifies an empty JSON response does not emit a frame.
/// </summary>
[Test]
public void CreateContentLengthFrame_WhenJsonIsEmpty_ReturnsEmptyFrame()
{
string frame = UnityCliLoopBridgeResponseWriter.CreateContentLengthFrame(string.Empty);

Assert.That(frame, Is.Empty);
}

/// <summary>
/// Verifies ASCII JSON is framed with its exact UTF-8 byte length and header separators.
/// </summary>
[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}"));
}

/// <summary>
/// Verifies multibyte JSON uses UTF-8 byte length rather than UTF-16 character count.
/// </summary>
[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\":\"あ\"}"));
}
}
}
11 changes: 11 additions & 0 deletions Assets/Tests/Editor/UnityCliLoopBridgeResponseWriterTests.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace io.github.hatayama.UnityCliLoop.Infrastructure
{
/// <summary>
/// Frames and serializes JSON responses written to project IPC client streams.
/// </summary>
internal static class UnityCliLoopBridgeResponseWriter
{
/// <summary>
/// Creates a Content-Length framed message for JSON-RPC 2.0 communication.
/// </summary>
/// <param name="jsonContent">The JSON content to frame</param>
/// <returns>The framed message with Content-Length header</returns>
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: <n>\r\n\r\n<json_content>
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();
}
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

67 changes: 4 additions & 63 deletions Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -575,45 +574,6 @@ private async Task HandleClientAsync(BridgeClientConnection client, Cancellation
}
}

/// <summary>
/// Creates a Content-Length framed message for JSON-RPC 2.0 communication.
/// </summary>
/// <param name="jsonContent">The JSON content to frame</param>
/// <returns>The framed message with Content-Length header</returns>
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: <n>\r\n\r\n<json_content>
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,
Expand All @@ -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)
Expand All @@ -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);
Expand All @@ -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
{
Expand All @@ -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();
}
}

/// <summary>
/// Determines if the given exception represents a normal client disconnection.
/// </summary>
Expand Down
Loading