diff --git a/Assets/Tests/Editor/JsonRpcHeartbeatTests.cs b/Assets/Tests/Editor/JsonRpcHeartbeatTests.cs index 404fd9fe5d..678fdd0e9c 100644 --- a/Assets/Tests/Editor/JsonRpcHeartbeatTests.cs +++ b/Assets/Tests/Editor/JsonRpcHeartbeatTests.cs @@ -17,7 +17,7 @@ public class JsonRpcHeartbeatTests public void CreateDispatchAcceptedResponse_WhenHeartbeatNegotiated_AdvertisesInterval() { // Tests that the dispatch ack tells a heartbeat-capable CLI which interval to expect. - string response = JsonRpcRequestProcessor.CreateDispatchAcceptedResponse(1, 10); + string response = JsonRpcResponseFactory.CreateDispatchAcceptedResponse(1, 10); JObject parsed = JObject.Parse(response); Assert.That(parsed["uloop"]["phase"].ToString(), Is.EqualTo(JsonRpcResponsePhases.Accepted)); @@ -29,7 +29,7 @@ public void CreateDispatchAcceptedResponse_WithoutHeartbeat_OmitsInterval() { // Tests that older CLIs that did not negotiate heartbeats get the legacy ack shape, // because they would treat unexpected extra frames as the final response. - string response = JsonRpcRequestProcessor.CreateDispatchAcceptedResponse(1, 0); + string response = JsonRpcResponseFactory.CreateDispatchAcceptedResponse(1, 0); JObject parsed = JObject.Parse(response); Assert.That(parsed["uloop"]["phase"].ToString(), Is.EqualTo(JsonRpcResponsePhases.Accepted)); @@ -40,7 +40,7 @@ public void CreateDispatchAcceptedResponse_WithoutHeartbeat_OmitsInterval() public void CreateHeartbeatResponse_WhenSerialized_CarriesPhaseAndStallSeconds() { // Tests the heartbeat frame shape the CLI parses for freeze diagnosis. - string response = JsonRpcRequestProcessor.CreateHeartbeatResponse(7, 12.5); + string response = JsonRpcResponseFactory.CreateHeartbeatResponse(7, 12.5); JObject parsed = JObject.Parse(response); Assert.That(parsed["uloop"]["phase"].ToString(), Is.EqualTo(JsonRpcResponsePhases.Heartbeat)); diff --git a/Assets/Tests/Editor/JsonRpcResponseFactoryWireShapeCharacterizationTests.cs b/Assets/Tests/Editor/JsonRpcResponseFactoryWireShapeCharacterizationTests.cs new file mode 100644 index 0000000000..1726f83609 --- /dev/null +++ b/Assets/Tests/Editor/JsonRpcResponseFactoryWireShapeCharacterizationTests.cs @@ -0,0 +1,178 @@ +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEngine.TestTools; + +using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.CompositionRoot; +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.Infrastructure; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Freezes the exact serialized JSON string for every JSON-RPC response shape + /// (success / error / dispatch-accepted / heartbeat / protocol-mismatch) before the + /// R3-5 move to JsonRpcResponseFactory, so the move can be verified byte-equal. + /// + public sealed class JsonRpcResponseFactoryWireShapeCharacterizationTests + { + [Test] + public async Task ProcessRequest_WhenToolSucceeds_ProducesFrozenSuccessJson() + { + // Verifies the success response wire shape is byte-equal to the frozen baseline. + UnityCliLoopToolRegistrarService service = UnityCliLoopToolRegistrarTestFactory.Create(UnityCliLoopToolDiscovery.DiscoverTools); + service.RegisterCustomTool(new DeterministicSuccessTool()); + JsonRpcRequestProcessor processor = CreateProcessor(service); + + string response = await processor.ProcessRequest(BuildToolRequest(DeterministicSuccessTool.Name, 1), CancellationToken.None); + + Assert.That(response, Is.EqualTo( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"Success\":true}}")); + } + + [Test] + public async Task ProcessRequest_WhenToolIsDisabled_ProducesFrozenErrorJson() + { + // Verifies the internal_error response wire shape is byte-equal to the frozen baseline. + InMemoryToolSettingsPort toolSettingsPort = new(); + toolSettingsPort.SetToolEnabled(DeterministicSuccessTool.Name, false); + UnityCliLoopToolRegistrarService service = new UnityCliLoopToolRegistrarService( + new EmptyInternalToolNameProvider(), + toolSettingsPort, + new UnityCliLoopToolExecutionService(new NoOpEditorRuntimeStatePort()), + UnityCliLoopToolDiscovery.DiscoverTools); + service.RegisterCustomTool(new DeterministicSuccessTool()); + JsonRpcRequestProcessor processor = CreateProcessor(service); + + LogAssert.Expect(UnityEngine.LogType.Error, new System.Text.RegularExpressions.Regex( + @"\[JsonRpcRequestProcessor\] Error: Tool 'deterministic-success' is disabled")); + string response = await processor.ProcessRequest(BuildToolRequest(DeterministicSuccessTool.Name, 1), CancellationToken.None); + + Assert.That(response, Is.EqualTo( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32603,\"message\":\"Tool 'deterministic-success' is disabled. To enable it, go to Window > Unity CLI Loop > Settings\",\"data\":{\"type\":\"internal_error\",\"message\":\"This tool has been disabled in project settings.\"}}}")); + } + + [Test] + public void CreateDispatchAcceptedResponse_WithHeartbeatNegotiated_ProducesFrozenJson() + { + // Verifies the dispatch-accepted-with-heartbeat wire shape is byte-equal to the frozen baseline. + string response = JsonRpcResponseFactory.CreateDispatchAcceptedResponse(1, 10); + + Assert.That(response, Is.EqualTo( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"accepted\":true},\"uloop\":{\"phase\":\"accepted\",\"heartbeatIntervalSeconds\":10}}")); + } + + [Test] + public void CreateDispatchAcceptedResponse_WithoutHeartbeat_ProducesFrozenJson() + { + // Verifies the dispatch-accepted-without-heartbeat wire shape is byte-equal to the frozen baseline. + string response = JsonRpcResponseFactory.CreateDispatchAcceptedResponse(1, 0); + + Assert.That(response, Is.EqualTo( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"accepted\":true},\"uloop\":{\"phase\":\"accepted\"}}")); + } + + [Test] + public void CreateHeartbeatResponse_WhenSerialized_ProducesFrozenJson() + { + // Verifies the heartbeat frame wire shape is byte-equal to the frozen baseline. + string response = JsonRpcResponseFactory.CreateHeartbeatResponse(7, 12.5); + + Assert.That(response, Is.EqualTo( + "{\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{\"alive\":true},\"uloop\":{\"phase\":\"heartbeat\",\"mainThreadStallSeconds\":12.5}}")); + } + + [Test] + public async Task ProcessRequest_WhenProtocolVersionIsTooOld_ProducesFrozenMismatchJson() + { + // Verifies the CLI-update-required wire shape is byte-equal to the frozen baseline. + UnityCliLoopToolRegistrarService service = UnityCliLoopToolRegistrarTestFactory.Create(UnityCliLoopToolDiscovery.DiscoverTools); + JsonRpcRequestProcessor processor = CreateProcessor(service); + + string request = + "{\"jsonrpc\":\"2.0\",\"method\":\"get-version\",\"params\":{},\"id\":1,\"uloop\":{\"protocolVersion\":" + + (CliConstants.REQUIRED_CLI_PROTOCOL_VERSION - 1) + + "}}"; + string response = await processor.ProcessRequest(request, CancellationToken.None); + + Assert.That(response, Is.EqualTo( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32603,\"message\":\"The installed uloop CLI uses an IPC protocol that does not match this Unity package.\",\"data\":{\"type\":\"cli_update_required\",\"currentCliVersion\":null,\"currentProtocolVersion\":" + + (CliConstants.REQUIRED_CLI_PROTOCOL_VERSION - 1) + + ",\"requiredProtocolVersion\":" + + CliConstants.REQUIRED_CLI_PROTOCOL_VERSION + + ",\"updateCommand\":\"uloop update\",\"retryableAfterUpdate\":true,\"message\":\"Install matching uloop CLI and Unity package versions, then retry the original command.\"}}}")); + } + + private static JsonRpcRequestProcessor CreateProcessor(UnityCliLoopToolRegistrarService service) + { + UnityCliLoopExecutionRouter executionRouter = new(service); + return new JsonRpcRequestProcessor(executionRouter); + } + + private static string BuildToolRequest(string toolName, int id) + { + return + "{\"jsonrpc\":\"2.0\",\"method\":\"" + + toolName + + "\",\"params\":{},\"id\":" + + id + + ",\"uloop\":{\"protocolVersion\":" + + CliConstants.REQUIRED_CLI_PROTOCOL_VERSION + + "}}"; + } + + private sealed class DeterministicSuccessResponse : UnityCliLoopToolResponse + { + public bool Success { get; set; } = true; + } + + private sealed class DeterministicSuccessTool : IUnityCliLoopTool + { + public const string Name = "deterministic-success"; + + public string ToolName => Name; + + public ToolParameterSchema ParameterSchema => new(); + + public Task ExecuteAsync(JToken paramsToken, CancellationToken ct) + { + return Task.FromResult(new DeterministicSuccessResponse()); + } + } + + private sealed class InMemoryToolSettingsPort : IToolSettingsPort + { + private readonly System.Collections.Generic.HashSet _disabledTools = new(); + + public bool IsToolEnabled(string toolName) + { + return !_disabledTools.Contains(toolName); + } + + public void SetToolEnabled(string toolName, bool enabled) + { + if (enabled) + { + _disabledTools.Remove(toolName); + return; + } + + _disabledTools.Add(toolName); + } + + public string[] GetDisabledTools() + { + string[] disabledTools = new string[_disabledTools.Count]; + _disabledTools.CopyTo(disabledTools); + return disabledTools; + } + + public void InvalidateCache() + { + } + } + } +} diff --git a/Assets/Tests/Editor/JsonRpcResponseFactoryWireShapeCharacterizationTests.cs.meta b/Assets/Tests/Editor/JsonRpcResponseFactoryWireShapeCharacterizationTests.cs.meta new file mode 100644 index 0000000000..7793dc1cb9 --- /dev/null +++ b/Assets/Tests/Editor/JsonRpcResponseFactoryWireShapeCharacterizationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2cfa7916fff33439a949c8bf4b74c22c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/Api/CliUpdateRequiredErrorData.cs b/Packages/src/Editor/Infrastructure/Api/CliUpdateRequiredErrorData.cs new file mode 100644 index 0000000000..541c0abd6e --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/CliUpdateRequiredErrorData.cs @@ -0,0 +1,36 @@ +using Newtonsoft.Json; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Carries protocol mismatch details plus optional CLI update instructions. + /// + public class CliUpdateRequiredErrorData : JsonRpcErrorData + { + public override string type => JsonRpcErrorTypes.CliUpdateRequired; + + public string currentCliVersion { get; } + + public int? currentProtocolVersion { get; } + + public int requiredProtocolVersion { get; } + + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public string updateCommand { get; } + + public bool retryableAfterUpdate { get; } + + public CliUpdateRequiredErrorData( + string currentCliVersion, + int? currentProtocolVersion, + int requiredProtocolVersion, + string updateCommand) : base("Install matching uloop CLI and Unity package versions, then retry the original command.") + { + this.currentCliVersion = string.IsNullOrWhiteSpace(currentCliVersion) ? null : currentCliVersion; + this.currentProtocolVersion = currentProtocolVersion; + this.requiredProtocolVersion = requiredProtocolVersion; + this.updateCommand = updateCommand; + retryableAfterUpdate = true; + } + } +} diff --git a/Packages/src/Editor/Infrastructure/Api/CliUpdateRequiredErrorData.cs.meta b/Packages/src/Editor/Infrastructure/Api/CliUpdateRequiredErrorData.cs.meta new file mode 100644 index 0000000000..1e26320da6 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/CliUpdateRequiredErrorData.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3cc2486f961ea48df8310a6e0a6508a8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/Api/InternalErrorData.cs b/Packages/src/Editor/Infrastructure/Api/InternalErrorData.cs new file mode 100644 index 0000000000..60cd0fef99 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/InternalErrorData.cs @@ -0,0 +1,14 @@ +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Error data for internal errors + /// + public class InternalErrorData : JsonRpcErrorData + { + public override string type => JsonRpcErrorTypes.InternalError; + + public InternalErrorData(string message) : base(message) + { + } + } +} diff --git a/Packages/src/Editor/Infrastructure/Api/InternalErrorData.cs.meta b/Packages/src/Editor/Infrastructure/Api/InternalErrorData.cs.meta new file mode 100644 index 0000000000..8a9592c9ec --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/InternalErrorData.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bf9c3c0f066f8433fb6c66f6f7666ae4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcError.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcError.cs new file mode 100644 index 0000000000..60558ea589 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcError.cs @@ -0,0 +1,21 @@ +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// JSON-RPC error object + /// + public class JsonRpcError + { + public int code { get; } + + public string message { get; } + + public JsonRpcErrorData data { get; } + + public JsonRpcError(int code, string message, JsonRpcErrorData data) + { + this.code = code; + this.message = message; + this.data = data; + } + } +} diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcError.cs.meta b/Packages/src/Editor/Infrastructure/Api/JsonRpcError.cs.meta new file mode 100644 index 0000000000..2b99f5b12e --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcError.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6642755ac33b843ad8b76cf7d190b473 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorData.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorData.cs new file mode 100644 index 0000000000..5d95a0429b --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorData.cs @@ -0,0 +1,17 @@ +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Base class for JSON-RPC error data + /// + public abstract class JsonRpcErrorData + { + public abstract string type { get; } + + public string message { get; protected set; } + + protected JsonRpcErrorData(string message) + { + this.message = message; + } + } +} diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorData.cs.meta b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorData.cs.meta new file mode 100644 index 0000000000..9b72792f1b --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorData.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8e947996c8660428fbf126c017e3a21c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorResponse.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorResponse.cs new file mode 100644 index 0000000000..c19f736761 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorResponse.cs @@ -0,0 +1,21 @@ +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// JSON-RPC error response + /// + public class JsonRpcErrorResponse + { + public string jsonrpc { get; } + + public object id { get; } + + public JsonRpcError error { get; } + + public JsonRpcErrorResponse(string jsonRpc, object id, JsonRpcError error) + { + this.jsonrpc = jsonRpc; + this.id = id; + this.error = error; + } + } +} diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorResponse.cs.meta b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorResponse.cs.meta new file mode 100644 index 0000000000..3235dbc151 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorResponse.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9fa02e1af45b74779be19677b0effd8f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorTypes.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorTypes.cs new file mode 100644 index 0000000000..a9796d39f2 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorTypes.cs @@ -0,0 +1,13 @@ +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Constants for JSON-RPC error types + /// + public static class JsonRpcErrorTypes + { + public const string SecurityBlocked = "security_blocked"; + public const string InternalError = "internal_error"; + public const string CliUpdateRequired = "cli_update_required"; + public const string ServerBusy = "server_busy"; + } +} diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorTypes.cs.meta b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorTypes.cs.meta new file mode 100644 index 0000000000..dac7e1894f --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorTypes.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c51fd9b4f0b1f4afd8044c7aab0f1422 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestProcessor.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestProcessor.cs index ff855598ac..f165f2788a 100644 --- a/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestProcessor.cs +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestProcessor.cs @@ -33,11 +33,6 @@ internal sealed class JsonRpcRequestProcessor { private readonly UnityCliLoopExecutionRouter _executionRouter; - // Shared by every response path; JsonConvert only reads the settings, so a single - // instance avoids allocating identical settings per response. - private static readonly JsonSerializerSettings ResponseSerializerSettings = - JsonRpcResponseSerializer.Settings; - internal delegate Task JsonRpcEarlyResponseWriter( string responseJson, bool cancelOnClientDisconnect, @@ -79,11 +74,11 @@ internal async Task ProcessRequestWithEarlyResponseAsync( } catch (JsonReaderException ex) { - return CreateErrorResponse(null, ex); + return JsonRpcResponseFactory.CreateErrorResponse(null, ex); } catch (Exception ex) when (!(ex is OperationCanceledException)) { - return CreateErrorResponse(null, ex); + return JsonRpcResponseFactory.CreateErrorResponse(null, ex); } } @@ -144,7 +139,7 @@ private async Task ProcessRpcRequest( ct.ThrowIfCancellationRequested(); if (IsCliProtocolMismatch(request.ClientProtocolVersion)) { - return CreateCliProtocolMismatchResponse( + return JsonRpcResponseFactory.CreateCliProtocolMismatchResponse( request.Id, request.ClientProjectRunnerVersion, request.ClientProtocolVersion); @@ -156,12 +151,12 @@ private async Task ProcessRpcRequest( ? UnityCliLoopServerConfig.HEARTBEAT_INTERVAL_SECONDS : 0; Func createHeartbeatJson = request.AcceptsHeartbeat - ? () => CreateHeartbeatResponse( + ? () => JsonRpcResponseFactory.CreateHeartbeatResponse( request.Id, EditorMainThreadLivenessTracker.SecondsSinceLastMainThreadTick()) : null; await earlyResponseWriter( - CreateDispatchAcceptedResponse(request.Id, heartbeatIntervalSeconds), + JsonRpcResponseFactory.CreateDispatchAcceptedResponse(request.Id, heartbeatIntervalSeconds), ShouldCancelAcceptedRequestOnClientDisconnect(request), createHeartbeatJson); } @@ -172,30 +167,30 @@ await earlyResponseWriter( UnityCliLoopToolResponse result = await ExecuteMethod(request.Method, request.Params, ct); executeMethodStopwatch.Stop(); - AppendTimingIfRequested( + JsonRpcResponseFactory.AppendTimingIfRequested( result, $"[Perf] RpcExecuteMethod: {executeMethodStopwatch.Elapsed.TotalMilliseconds:F1}ms"); - AppendTimingIfRequested( + JsonRpcResponseFactory.AppendTimingIfRequested( result, $"[Perf] RpcBeforeSerializeTotal: {requestStopwatch.Elapsed.TotalMilliseconds:F1}ms"); - string response = CreateSuccessResponse(request.Id, result); + string response = JsonRpcResponseFactory.CreateSuccessResponse(request.Id, result); return response; } catch (JsonSerializationException ex) { UnityEngine.Debug.LogError($"[JsonRpcRequestProcessor] JSON serialization error: {ex.Message}\nStack trace: {ex.StackTrace}"); - return CreateErrorResponse(request.Id, ex); + return JsonRpcResponseFactory.CreateErrorResponse(request.Id, ex); } catch (UnityCliLoopToolParameterValidationException ex) { LogUnityCliLoopToolParameterValidationException(ex); - return CreateErrorResponse(request.Id, ex); + return JsonRpcResponseFactory.CreateErrorResponse(request.Id, ex); } catch (Exception ex) when (!(ex is OperationCanceledException)) { LogRpcExceptionIfNeeded(ex); - return CreateErrorResponse(request.Id, ex); + return JsonRpcResponseFactory.CreateErrorResponse(request.Id, ex); } } @@ -230,193 +225,12 @@ private static bool IsCliProtocolMismatch(int? currentProtocolVersion) return currentProtocolVersion.Value != CliConstants.REQUIRED_CLI_PROTOCOL_VERSION; } - private static string CreateCliProtocolMismatchResponse( - object id, - string currentCliVersion, - int? currentProtocolVersion) - { - JsonRpcErrorResponse errorResponse = new( - UnityCliLoopServerConfig.JSONRPC_VERSION, - id, - new JsonRpcError( - UnityCliLoopServerConfig.INTERNAL_ERROR_CODE, - "The installed uloop CLI uses an IPC protocol that does not match this Unity package.", - new CliUpdateRequiredErrorData( - currentCliVersion, - currentProtocolVersion, - CliConstants.REQUIRED_CLI_PROTOCOL_VERSION, - GetCliUpdateCommandForProtocolMismatch(currentProtocolVersion)))); - - return JsonConvert.SerializeObject(errorResponse, Formatting.None, ResponseSerializerSettings); - } - - private static string GetCliUpdateCommandForProtocolMismatch(int? currentProtocolVersion) - { - if (currentProtocolVersion == null) - { - return CreateCliUpdateCommand(); - } - - if (currentProtocolVersion.Value < CliConstants.REQUIRED_CLI_PROTOCOL_VERSION) - { - return CreateCliUpdateCommand(); - } - - return null; - } - - private static string CreateCliUpdateCommand() - { - return $"{CliConstants.EXECUTABLE_NAME} update"; - } - - internal static string CreateDispatchAcceptedResponse(object id, int heartbeatIntervalSeconds) - { - // heartbeatIntervalSeconds is only advertised when the client negotiated - // heartbeats; older CLIs would treat unexpected extra frames as the final - // response, so the field doubles as the negotiation answer. - object uloopMetadata = heartbeatIntervalSeconds > 0 - ? new - { - phase = JsonRpcResponsePhases.Accepted, - heartbeatIntervalSeconds - } - : (object)new - { - phase = JsonRpcResponsePhases.Accepted - }; - - object response = new - { - jsonrpc = UnityCliLoopServerConfig.JSONRPC_VERSION, - id, - result = new - { - accepted = true - }, - uloop = uloopMetadata - }; - - return JsonConvert.SerializeObject(response, Formatting.None, ResponseSerializerSettings); - } - - internal static string CreateHeartbeatResponse(object id, double mainThreadStallSeconds) - { - object response = new - { - jsonrpc = UnityCliLoopServerConfig.JSONRPC_VERSION, - id, - result = new - { - alive = true - }, - uloop = new - { - phase = JsonRpcResponsePhases.Heartbeat, - mainThreadStallSeconds - } - }; - - return JsonConvert.SerializeObject(response, Formatting.None, ResponseSerializerSettings); - } - - private static void AppendTimingIfRequested(UnityCliLoopToolResponse result, string timing) - { - if (result is not IUnityCliLoopTimingResponse timingResponse) - { - return; - } - - if (!timingResponse.EmitsTimingsInJsonResponse) - { - return; - } - - timingResponse.AddTiming(timing); - } - private static void LogUnityCliLoopToolParameterValidationException(UnityCliLoopToolParameterValidationException exception) { UnityEngine.Debug.LogError( $"[JsonRpcRequestProcessor] Parameter validation error: {exception.Message}\nStack trace: {exception.StackTrace}"); } - /// - /// Create JSON-RPC success response - /// - /// Request ID - must be same type as received (string/number/null per JSON-RPC spec) - /// Command execution result - private static string CreateSuccessResponse(object id, UnityCliLoopToolResponse result) - { - try - { - JsonRpcSuccessResponse response = new( - UnityCliLoopServerConfig.JSONRPC_VERSION, - id, - result - ); - return JsonConvert.SerializeObject(response, Formatting.None, ResponseSerializerSettings); - } - catch (Exception) - { - // Return safe fallback response for any serialization errors - object fallbackResult = new - { - error = "Serialization failed - returning safe fallback", - commandType = result != null ? result.GetType().Name : "unknown" - }; - - JsonRpcSuccessResponse fallbackResponse = new( - UnityCliLoopServerConfig.JSONRPC_VERSION, - id, - fallbackResult - ); - return JsonConvert.SerializeObject(fallbackResponse, Formatting.None, ResponseSerializerSettings); - } - } - - /// - /// Create JSON-RPC error response - /// - /// Request ID - must be same type as received (string/number/null per JSON-RPC spec) - /// Exception to convert to error response - private static string CreateErrorResponse(object id, Exception ex) - { - // Centralize exception -> user-facing message via UserFriendlyErrorConverter - UserFriendlyErrorConverter handler = new(); - UserFriendlyErrorDto exceptionResponse = handler.ProcessException(ex); - - // Map UserFriendlyErrorDto to JsonRpcError - string errorMessage = exceptionResponse.FriendlyMessage; - - JsonRpcErrorData errorData; - if (ex is UnityCliLoopSecurityException secEx) - { - errorData = new SecurityBlockedErrorData(secEx.ToolName, secEx.SecurityReason, exceptionResponse.Explanation ?? ex.Message); - } - else if (ex is UnityCliLoopToolBusyException busyEx) - { - errorData = new ServerBusyErrorData( - busyEx.RunningToolName, - busyEx.RequestedToolName, - busyEx.IsPlaying, - busyEx.IsPaused, - exceptionResponse.Explanation ?? ex.Message); - } - else - { - errorData = new InternalErrorData(exceptionResponse.Explanation ?? ex.Message); - } - - JsonRpcErrorResponse errorResponse = new( - UnityCliLoopServerConfig.JSONRPC_VERSION, - id, - new JsonRpcError(UnityCliLoopServerConfig.INTERNAL_ERROR_CODE, errorMessage, errorData) - ); - - return JsonConvert.SerializeObject(errorResponse, Formatting.None, ResponseSerializerSettings); - } - /// /// Execute appropriate handler according to method name /// Use new command-based structure @@ -429,187 +243,5 @@ private async Task ExecuteMethod( return await _executionRouter.ExecuteAsync(method, paramsToken, ct); } } +} - /// - /// Constants for JSON-RPC error types - /// - public static class JsonRpcErrorTypes - { - public const string SecurityBlocked = "security_blocked"; - public const string InternalError = "internal_error"; - public const string CliUpdateRequired = "cli_update_required"; - public const string ServerBusy = "server_busy"; - } - - public static class JsonRpcResponsePhases - { - public const string Accepted = "accepted"; - public const string Heartbeat = "heartbeat"; - } - - /// - /// Base class for JSON-RPC error data - /// - public abstract class JsonRpcErrorData - { - public abstract string type { get; } - - public string message { get; protected set; } - - protected JsonRpcErrorData(string message) - { - this.message = message; - } - } - - /// - /// Error data for security blocked commands - /// - public class SecurityBlockedErrorData : JsonRpcErrorData - { - public override string type => JsonRpcErrorTypes.SecurityBlocked; - - public string command { get; } - - public string reason { get; } - - public SecurityBlockedErrorData(string command, string reason, string message) : base(message) - { - this.command = command; - this.reason = reason; - } - } - - /// - /// Error data for internal errors - /// - public class InternalErrorData : JsonRpcErrorData - { - public override string type => JsonRpcErrorTypes.InternalError; - - public InternalErrorData(string message) : base(message) - { - } - } - - /// - /// Keeps busy responses machine-readable so CLI clients can classify them as retryable. - /// - public class ServerBusyErrorData : JsonRpcErrorData - { - public override string type => JsonRpcErrorTypes.ServerBusy; - - public string runningToolName { get; } - - public string requestedToolName { get; } - - [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public bool? isPlaying { get; } - - [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public bool? isPaused { get; } - - public ServerBusyErrorData( - string runningToolName, - string requestedToolName, - bool? isPlaying, - bool? isPaused, - string message) - : base(message) - { - this.runningToolName = runningToolName; - this.requestedToolName = requestedToolName; - this.isPlaying = isPlaying; - this.isPaused = isPaused; - } - } - - /// - /// Carries protocol mismatch details plus optional CLI update instructions. - /// - public class CliUpdateRequiredErrorData : JsonRpcErrorData - { - public override string type => JsonRpcErrorTypes.CliUpdateRequired; - - public string currentCliVersion { get; } - - public int? currentProtocolVersion { get; } - - public int requiredProtocolVersion { get; } - - [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public string updateCommand { get; } - - public bool retryableAfterUpdate { get; } - - public CliUpdateRequiredErrorData( - string currentCliVersion, - int? currentProtocolVersion, - int requiredProtocolVersion, - string updateCommand) : base("Install matching uloop CLI and Unity package versions, then retry the original command.") - { - this.currentCliVersion = string.IsNullOrWhiteSpace(currentCliVersion) ? null : currentCliVersion; - this.currentProtocolVersion = currentProtocolVersion; - this.requiredProtocolVersion = requiredProtocolVersion; - this.updateCommand = updateCommand; - retryableAfterUpdate = true; - } - } - - /// - /// JSON-RPC error object - /// - public class JsonRpcError - { - public int code { get; } - - public string message { get; } - - public JsonRpcErrorData data { get; } - - public JsonRpcError(int code, string message, JsonRpcErrorData data) - { - this.code = code; - this.message = message; - this.data = data; - } - } - - /// - /// JSON-RPC success response - /// - public class JsonRpcSuccessResponse - { - public string jsonrpc { get; } - - public object id { get; } - - public object result { get; } - - public JsonRpcSuccessResponse(string jsonRpc, object id, object result) - { - this.jsonrpc = jsonRpc; - this.id = id; - this.result = result; - } - } - - /// - /// JSON-RPC error response - /// - public class JsonRpcErrorResponse - { - public string jsonrpc { get; } - - public object id { get; } - - public JsonRpcError error { get; } - - public JsonRpcErrorResponse(string jsonRpc, object id, JsonRpcError error) - { - this.jsonrpc = jsonRpc; - this.id = id; - this.error = error; - } - } -} diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcResponseFactory.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcResponseFactory.cs new file mode 100644 index 0000000000..24f692b03c --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcResponseFactory.cs @@ -0,0 +1,202 @@ +using System; +using Newtonsoft.Json; + +using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Owns the wire shape of every JSON-RPC response frame: success, error, + /// dispatch-accepted, heartbeat, and CLI protocol mismatch. + /// + internal static class JsonRpcResponseFactory + { + // Shared by every response path; JsonConvert only reads the settings, so a single + // instance avoids allocating identical settings per response. + private static readonly JsonSerializerSettings ResponseSerializerSettings = + JsonRpcResponseSerializer.Settings; + + /// + /// Create JSON-RPC success response + /// + /// Request ID - must be same type as received (string/number/null per JSON-RPC spec) + /// Command execution result + internal static string CreateSuccessResponse(object id, UnityCliLoopToolResponse result) + { + try + { + JsonRpcSuccessResponse response = new( + UnityCliLoopServerConfig.JSONRPC_VERSION, + id, + result + ); + return JsonConvert.SerializeObject(response, Formatting.None, ResponseSerializerSettings); + } + catch (Exception) + { + // Return safe fallback response for any serialization errors + object fallbackResult = new + { + error = "Serialization failed - returning safe fallback", + commandType = result != null ? result.GetType().Name : "unknown" + }; + + JsonRpcSuccessResponse fallbackResponse = new( + UnityCliLoopServerConfig.JSONRPC_VERSION, + id, + fallbackResult + ); + return JsonConvert.SerializeObject(fallbackResponse, Formatting.None, ResponseSerializerSettings); + } + } + + /// + /// Create JSON-RPC error response + /// + /// Request ID - must be same type as received (string/number/null per JSON-RPC spec) + /// Exception to convert to error response + internal static string CreateErrorResponse(object id, Exception ex) + { + // Centralize exception -> user-facing message via UserFriendlyErrorConverter + UserFriendlyErrorConverter handler = new(); + UserFriendlyErrorDto exceptionResponse = handler.ProcessException(ex); + + // Map UserFriendlyErrorDto to JsonRpcError + string errorMessage = exceptionResponse.FriendlyMessage; + + JsonRpcErrorData errorData; + if (ex is UnityCliLoopSecurityException secEx) + { + errorData = new SecurityBlockedErrorData(secEx.ToolName, secEx.SecurityReason, exceptionResponse.Explanation ?? ex.Message); + } + else if (ex is UnityCliLoopToolBusyException busyEx) + { + errorData = new ServerBusyErrorData( + busyEx.RunningToolName, + busyEx.RequestedToolName, + busyEx.IsPlaying, + busyEx.IsPaused, + exceptionResponse.Explanation ?? ex.Message); + } + else + { + errorData = new InternalErrorData(exceptionResponse.Explanation ?? ex.Message); + } + + JsonRpcErrorResponse errorResponse = new( + UnityCliLoopServerConfig.JSONRPC_VERSION, + id, + new JsonRpcError(UnityCliLoopServerConfig.INTERNAL_ERROR_CODE, errorMessage, errorData) + ); + + return JsonConvert.SerializeObject(errorResponse, Formatting.None, ResponseSerializerSettings); + } + + internal static string CreateDispatchAcceptedResponse(object id, int heartbeatIntervalSeconds) + { + // heartbeatIntervalSeconds is only advertised when the client negotiated + // heartbeats; older CLIs would treat unexpected extra frames as the final + // response, so the field doubles as the negotiation answer. + object uloopMetadata = heartbeatIntervalSeconds > 0 + ? new + { + phase = JsonRpcResponsePhases.Accepted, + heartbeatIntervalSeconds + } + : (object)new + { + phase = JsonRpcResponsePhases.Accepted + }; + + object response = new + { + jsonrpc = UnityCliLoopServerConfig.JSONRPC_VERSION, + id, + result = new + { + accepted = true + }, + uloop = uloopMetadata + }; + + return JsonConvert.SerializeObject(response, Formatting.None, ResponseSerializerSettings); + } + + internal static string CreateHeartbeatResponse(object id, double mainThreadStallSeconds) + { + object response = new + { + jsonrpc = UnityCliLoopServerConfig.JSONRPC_VERSION, + id, + result = new + { + alive = true + }, + uloop = new + { + phase = JsonRpcResponsePhases.Heartbeat, + mainThreadStallSeconds + } + }; + + return JsonConvert.SerializeObject(response, Formatting.None, ResponseSerializerSettings); + } + + internal static string CreateCliProtocolMismatchResponse( + object id, + string currentCliVersion, + int? currentProtocolVersion) + { + JsonRpcErrorResponse errorResponse = new( + UnityCliLoopServerConfig.JSONRPC_VERSION, + id, + new JsonRpcError( + UnityCliLoopServerConfig.INTERNAL_ERROR_CODE, + "The installed uloop CLI uses an IPC protocol that does not match this Unity package.", + new CliUpdateRequiredErrorData( + currentCliVersion, + currentProtocolVersion, + CliConstants.REQUIRED_CLI_PROTOCOL_VERSION, + GetCliUpdateCommandForProtocolMismatch(currentProtocolVersion)))); + + return JsonConvert.SerializeObject(errorResponse, Formatting.None, ResponseSerializerSettings); + } + + private static string GetCliUpdateCommandForProtocolMismatch(int? currentProtocolVersion) + { + if (currentProtocolVersion == null) + { + return CreateCliUpdateCommand(); + } + + if (currentProtocolVersion.Value < CliConstants.REQUIRED_CLI_PROTOCOL_VERSION) + { + return CreateCliUpdateCommand(); + } + + return null; + } + + private static string CreateCliUpdateCommand() + { + return $"{CliConstants.EXECUTABLE_NAME} update"; + } + + internal static void AppendTimingIfRequested(UnityCliLoopToolResponse result, string timing) + { + if (result is not IUnityCliLoopTimingResponse timingResponse) + { + return; + } + + if (!timingResponse.EmitsTimingsInJsonResponse) + { + return; + } + + timingResponse.AddTiming(timing); + } + } +} diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcResponseFactory.cs.meta b/Packages/src/Editor/Infrastructure/Api/JsonRpcResponseFactory.cs.meta new file mode 100644 index 0000000000..99fc9ab5ad --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcResponseFactory.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 72860040883c048c78cb67ccb8d5c39f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcResponsePhases.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcResponsePhases.cs new file mode 100644 index 0000000000..0b1dc1dbbb --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcResponsePhases.cs @@ -0,0 +1,8 @@ +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + public static class JsonRpcResponsePhases + { + public const string Accepted = "accepted"; + public const string Heartbeat = "heartbeat"; + } +} diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcResponsePhases.cs.meta b/Packages/src/Editor/Infrastructure/Api/JsonRpcResponsePhases.cs.meta new file mode 100644 index 0000000000..97faeea3ee --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcResponsePhases.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0526adfb147024f46af583be80987102 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcSuccessResponse.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcSuccessResponse.cs new file mode 100644 index 0000000000..cb2d1fdec1 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcSuccessResponse.cs @@ -0,0 +1,21 @@ +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// JSON-RPC success response + /// + public class JsonRpcSuccessResponse + { + public string jsonrpc { get; } + + public object id { get; } + + public object result { get; } + + public JsonRpcSuccessResponse(string jsonRpc, object id, object result) + { + this.jsonrpc = jsonRpc; + this.id = id; + this.result = result; + } + } +} diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcSuccessResponse.cs.meta b/Packages/src/Editor/Infrastructure/Api/JsonRpcSuccessResponse.cs.meta new file mode 100644 index 0000000000..cf54458031 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcSuccessResponse.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d9be8bf4a8ee444efa1adc14ccdf0794 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/Api/SecurityBlockedErrorData.cs b/Packages/src/Editor/Infrastructure/Api/SecurityBlockedErrorData.cs new file mode 100644 index 0000000000..348cdea782 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/SecurityBlockedErrorData.cs @@ -0,0 +1,20 @@ +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Error data for security blocked commands + /// + public class SecurityBlockedErrorData : JsonRpcErrorData + { + public override string type => JsonRpcErrorTypes.SecurityBlocked; + + public string command { get; } + + public string reason { get; } + + public SecurityBlockedErrorData(string command, string reason, string message) : base(message) + { + this.command = command; + this.reason = reason; + } + } +} diff --git a/Packages/src/Editor/Infrastructure/Api/SecurityBlockedErrorData.cs.meta b/Packages/src/Editor/Infrastructure/Api/SecurityBlockedErrorData.cs.meta new file mode 100644 index 0000000000..9dfc71b9fe --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/SecurityBlockedErrorData.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 431d943a1f3f746279383089fbb7d729 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/Api/ServerBusyErrorData.cs b/Packages/src/Editor/Infrastructure/Api/ServerBusyErrorData.cs new file mode 100644 index 0000000000..bae0e015d1 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/ServerBusyErrorData.cs @@ -0,0 +1,36 @@ +using Newtonsoft.Json; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Keeps busy responses machine-readable so CLI clients can classify them as retryable. + /// + public class ServerBusyErrorData : JsonRpcErrorData + { + public override string type => JsonRpcErrorTypes.ServerBusy; + + public string runningToolName { get; } + + public string requestedToolName { get; } + + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public bool? isPlaying { get; } + + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public bool? isPaused { get; } + + public ServerBusyErrorData( + string runningToolName, + string requestedToolName, + bool? isPlaying, + bool? isPaused, + string message) + : base(message) + { + this.runningToolName = runningToolName; + this.requestedToolName = requestedToolName; + this.isPlaying = isPlaying; + this.isPaused = isPaused; + } + } +} diff --git a/Packages/src/Editor/Infrastructure/Api/ServerBusyErrorData.cs.meta b/Packages/src/Editor/Infrastructure/Api/ServerBusyErrorData.cs.meta new file mode 100644 index 0000000000..1ed2503469 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/ServerBusyErrorData.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cd20357254e574ceabfbf2aa84f2a975 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: