diff --git a/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeClientSessionManager.cs b/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeClientSessionManager.cs new file mode 100644 index 0000000000..26a42bcba9 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeClientSessionManager.cs @@ -0,0 +1,358 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Owns connected client streams, handler tasks, and per-request project IPC session processing. + /// + internal sealed class UnityCliLoopBridgeClientSessionManager + { + private readonly JsonRpcRequestProcessor _jsonRpcRequestProcessor; + private readonly UnityCliLoopBridgeHeartbeatService _heartbeatService; + private readonly UnityCliLoopBridgeClientDisconnectMonitor _clientDisconnectMonitor; + + // HResult error codes for normal disconnection detection + private static readonly HashSet NormalDisconnectionHResults = new() + { + unchecked((int)0x800703E3), // ERROR_OPERATION_ABORTED + unchecked((int)0x80070040), // ERROR_NETNAME_DELETED + unchecked((int)0x80072745), // ERROR_CONNECTION_ABORTED + unchecked((int)0x80072746) // ERROR_CONNECTION_RESET + }; + + private readonly ConcurrentDictionary _clientStreams = new(); + private readonly ConcurrentDictionary _clientTasks = new(); + private int _nextClientTaskId; + + internal UnityCliLoopBridgeClientSessionManager( + JsonRpcRequestProcessor jsonRpcRequestProcessor, + UnityCliLoopBridgeHeartbeatService heartbeatService, + UnityCliLoopBridgeClientDisconnectMonitor clientDisconnectMonitor) + { + System.Diagnostics.Debug.Assert(jsonRpcRequestProcessor != null, "jsonRpcRequestProcessor must not be null"); + System.Diagnostics.Debug.Assert(heartbeatService != null, "heartbeatService must not be null"); + System.Diagnostics.Debug.Assert(clientDisconnectMonitor != null, "clientDisconnectMonitor must not be null"); + + _jsonRpcRequestProcessor = jsonRpcRequestProcessor; + _heartbeatService = heartbeatService; + _clientDisconnectMonitor = clientDisconnectMonitor; + } + + /// + /// Explicitly disconnect all connected clients + /// This ensures CLI clients receive proper close events + /// + internal void DisconnectAllClients() + { + if (_clientStreams.IsEmpty) + { + return; + } + + List clientsToRemove = new(); + + foreach (KeyValuePair client in _clientStreams) + { + try + { + if (client.Value != null && client.Value.CanWrite) + { + client.Value.Close(); + } + clientsToRemove.Add(client.Key); + } + catch (Exception ex) + { + VibeLogger.LogWarning( + "client_disconnect_failed", + $"Error disconnecting client {client.Key}: {ex.Message}"); + clientsToRemove.Add(client.Key); // Remove even if disconnect failed + } + } + + // Remove all clients from the connected clients list + foreach (string clientKey in clientsToRemove) + { + _clientStreams.TryRemove(clientKey, out _); + } + } + + internal void StartClientHandler(BridgeClientConnection client, CancellationToken cancellationToken) + { + int taskId = Interlocked.Increment(ref _nextClientTaskId); + Task clientTask = Task.Run(() => HandleClientAsync(client, cancellationToken)); + _clientTasks.TryAdd(taskId, clientTask); + clientTask.ContinueWith( + task => + { + _clientTasks.TryRemove(taskId, out _); + if (task.IsFaulted && task.Exception != null) + { + // HandleClientAsync catches expected failures itself, so a faulted task is + // an anomaly worth surfacing in the console for non-debug installs. + Debug.LogError( + $"[{UnityCliLoopConstants.PROJECT_NAME}] Client handler task faulted: {task.Exception.GetBaseException()}"); + } + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + internal Task[] GetActiveClientTasks() + { + return _clientTasks.Values.ToArray(); + } + + /// + /// Handles communication with the client using Content-Length framing. + /// + private async Task HandleClientAsync(BridgeClientConnection client, CancellationToken cancellationToken) + { + string clientKey = client.Endpoint; + + // Initialize new components for Content-Length framing + DynamicBufferManager bufferManager = null; + MessageReassembler messageReassembler = null; + + try + { + using (client) + using (Stream stream = client.Stream) + { + + // Check for existing connection from same endpoint and close it + if (_clientStreams.TryRemove(clientKey, out Stream existingStream)) + { + existingStream?.Close(); + } + + _clientStreams.TryAdd(clientKey, stream); + + // Initialize new framing components + bufferManager = new DynamicBufferManager(); + messageReassembler = new MessageReassembler(bufferManager); + + // Not disposed deliberately: a heartbeat writer could still be releasing it + // during teardown, and an undisposed SemaphoreSlim holds no unmanaged state. + SemaphoreSlim streamWriteLock = new(1, 1); + + // Start with initial buffer size + byte[] buffer = bufferManager.GetBuffer(BufferConfig.INITIAL_BUFFER_SIZE); + + while (!cancellationToken.IsCancellationRequested) + { + int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken); + + if (bytesRead == 0) + { + break; // Client disconnected. + } + + // Add received data to message reassembler + messageReassembler.AddData(buffer, bytesRead); + + // Extract any complete messages + string[] completeJsonMessages = messageReassembler.ExtractCompleteMessages(); + + foreach (string requestJson in completeJsonMessages) + { + if (string.IsNullOrWhiteSpace(requestJson)) continue; + + await ProcessRequestFrameAsync(client, stream, streamWriteLock, requestJson, cancellationToken); + } + + // Why: false means the reassembler was disposed and can no longer make + // progress; closing the connection lets the CLI observe the disconnect + // immediately instead of waiting silently for its own timeout. + // (Corrupted framing state throws from ValidateState and is handled below.) + if (!messageReassembler.ValidateState()) + { + break; + } + } + } + } + catch (ThreadAbortException) + { + // Treat as normal behavior if a domain reload is in progress. + // No need to log thread aborts during domain reload + } + catch (OperationCanceledException) + { + // Normal cancellation during server shutdown or domain reload + // No logging needed as this is expected behavior during Unity Editor operations + } + catch (IOException ex) + { + // I/O errors are usually normal disconnections - only log as info instead of warning + if (IsNormalDisconnectionException(ex)) + { + // Log normal disconnections as info level + } + } + catch (Exception ex) + { + // Expected failures (cancellation, reload aborts, normal disconnects) are filtered + // above, so anything reaching here means a CLI session died abnormally and the + // console must say so even on installs where VibeLogger is compiled out. + Debug.LogError( + $"[{UnityCliLoopConstants.PROJECT_NAME}] Client session for {clientKey} failed: {ex}"); + } + finally + { + // Dispose of framing components + try + { + messageReassembler?.Dispose(); + bufferManager?.Dispose(); + } + catch (Exception ex) + { + VibeLogger.LogWarning( + "client_dispose_failed", + $"Error during client disposal: {ex.Message}"); + } + + _clientStreams.TryRemove(clientKey, out _); + + client.Dispose(); + } + } + + private async Task ProcessRequestFrameAsync( + BridgeClientConnection client, + Stream stream, + SemaphoreSlim streamWriteLock, + string requestJson, + CancellationToken serverCancellationToken) + { + using (CancellationTokenSource requestCancellationTokenSource = + CancellationTokenSource.CreateLinkedTokenSource(serverCancellationToken)) + { + Task clientDisconnectMonitorTask = null; + Task heartbeatTask = null; + CancellationTokenSource heartbeatCancellationSource = null; + try + { + string responseJson = await _jsonRpcRequestProcessor.ProcessRequestWithEarlyResponseAsync( + requestJson, + requestCancellationTokenSource.Token, + async (responseJsonValue, cancelOnClientDisconnect, createHeartbeatJson) => + { + await UnityCliLoopBridgeResponseWriter.WriteJsonResponseLockedAsync( + stream, streamWriteLock, responseJsonValue, serverCancellationToken); + + if (createHeartbeatJson != null) + { + heartbeatCancellationSource = CancellationTokenSource.CreateLinkedTokenSource( + requestCancellationTokenSource.Token); + // Why: the heartbeat token must govern the write as well; with the + // server token an in-flight write could ignore StopHeartbeatsAsync + // and stall the final response behind a slow client. + CancellationToken heartbeatToken = heartbeatCancellationSource.Token; + heartbeatTask = _heartbeatService.SendHeartbeatsAsync( + createHeartbeatJson, + heartbeatJson => UnityCliLoopBridgeResponseWriter.WriteJsonResponseLockedAsync( + stream, streamWriteLock, heartbeatJson, heartbeatToken), + TimeSpan.FromSeconds(UnityCliLoopServerConfig.HEARTBEAT_INTERVAL_SECONDS), + heartbeatToken); + } + + if (!cancelOnClientDisconnect) + { + return; + } + + clientDisconnectMonitorTask = + _clientDisconnectMonitor.MonitorClientDisconnectAsync( + client, + requestCancellationTokenSource); + }); + + // Stop heartbeats before the final response so no heartbeat frame can be + // queued after the response the CLI stops reading at. + await _heartbeatService.StopHeartbeatsAsync(heartbeatTask, heartbeatCancellationSource); + heartbeatTask = null; + + await UnityCliLoopBridgeResponseWriter.WriteJsonResponseLockedAsync( + stream, streamWriteLock, responseJson, serverCancellationToken); + } + finally + { + await _heartbeatService.StopHeartbeatsAsync(heartbeatTask, heartbeatCancellationSource); + heartbeatCancellationSource?.Dispose(); + await _clientDisconnectMonitor.StopClientDisconnectMonitorAsync( + clientDisconnectMonitorTask, + requestCancellationTokenSource); + } + } + } + + /// + /// Determines if the given exception represents a normal client disconnection. + /// + /// The exception to evaluate + /// True if the exception represents a normal disconnection, false otherwise + private static bool IsNormalDisconnectionException(Exception ex) + { + switch (ex) + { + case SocketException sockEx: + return sockEx.SocketErrorCode is SocketError.ConnectionReset or + SocketError.ConnectionAborted or + SocketError.OperationAborted or + SocketError.Shutdown or + SocketError.NotConnected; + + case ObjectDisposedException: + return true; + + case IOException ioEx when ioEx.InnerException is SocketException innerSockEx: + return innerSockEx.SocketErrorCode is SocketError.ConnectionReset or + SocketError.ConnectionAborted or + SocketError.OperationAborted or + SocketError.Shutdown or + SocketError.NotConnected; + + case IOException ioEx: + // Check HResult codes for common disconnection scenarios + return NormalDisconnectionHResults.Contains(ioEx.HResult) || + IsNormalDisconnectionByInnerException(ioEx); + + default: + return false; + } + } + + /// + /// Recursively checks inner exceptions for normal disconnection scenarios + /// + /// The exception to check + /// True if any inner exception indicates a normal disconnection + private static bool IsNormalDisconnectionByInnerException(Exception ex) + { + Exception innerEx = ex.InnerException; + while (innerEx != null) + { + if (IsNormalDisconnectionException(innerEx)) + { + return true; + } + innerEx = innerEx.InnerException; + } + return false; + } + } +} diff --git a/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeClientSessionManager.cs.meta b/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeClientSessionManager.cs.meta new file mode 100644 index 0000000000..28b69480dc --- /dev/null +++ b/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeClientSessionManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e62d177cf2934c958c6f86185877f86b +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 e0575d39c3..d3894afe97 100644 --- a/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServer.cs +++ b/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServer.cs @@ -1,7 +1,5 @@ using System; using System.Collections.Generic; -using System.Collections.Concurrent; -using System.IO; using System.Linq; using System.Net.Sockets; using System.Threading; @@ -14,52 +12,6 @@ namespace io.github.hatayama.UnityCliLoop.Infrastructure { - /// - /// Creates Unity CLI Loop Bridge Server Instance instances with the dependencies required by this module. - /// - public sealed class UnityCliLoopBridgeServerInstanceFactory : - IUnityCliLoopServerInstanceFactory, - IUnityCliLoopServerLifecycleSource - { - public event Action ServerLoopExited; - private readonly IDomainReloadDetectionService _domainReloadDetectionService; - private readonly UnityCliLoopToolRegistrarService _toolRegistrarService; - - internal UnityCliLoopBridgeServerInstanceFactory( - IDomainReloadDetectionService domainReloadDetectionService, - UnityCliLoopToolRegistrarService toolRegistrarService) - { - System.Diagnostics.Debug.Assert(domainReloadDetectionService != null, "domainReloadDetectionService must not be null"); - System.Diagnostics.Debug.Assert(toolRegistrarService != null, "toolRegistrarService must not be null"); - - _domainReloadDetectionService = domainReloadDetectionService - ?? throw new ArgumentNullException(nameof(domainReloadDetectionService)); - _toolRegistrarService = toolRegistrarService - ?? throw new ArgumentNullException(nameof(toolRegistrarService)); - } - - public IUnityCliLoopServerInstance Create() - { - UnityCliLoopBridgeHeartbeatService heartbeatService = new(); - UnityCliLoopBridgeClientDisconnectMonitor clientDisconnectMonitor = new(); - UnityCliLoopExecutionRouter executionRouter = new(_toolRegistrarService); - JsonRpcRequestProcessor jsonRpcRequestProcessor = new(executionRouter); - UnityCliLoopBridgeServer server = new( - _domainReloadDetectionService, - jsonRpcRequestProcessor, - heartbeatService, - clientDisconnectMonitor); - server.ServerLoopExited += NotifyServerLoopExited; - - return server; - } - - private void NotifyServerLoopExited() - { - ServerLoopExited?.Invoke(); - } - } - /// /// Unity CLI bridge server. /// Accepts project-local CLI connections and handles JSON-RPC 2.0 communication. @@ -70,18 +22,7 @@ public class UnityCliLoopBridgeServer : IUnityCliLoopServerInstance // Subscribers must marshal to main thread before accessing Unity APIs. public event Action ServerLoopExited; private readonly IDomainReloadDetectionService _domainReloadDetectionService; - private readonly JsonRpcRequestProcessor _jsonRpcRequestProcessor; - private readonly UnityCliLoopBridgeHeartbeatService _heartbeatService; - private readonly UnityCliLoopBridgeClientDisconnectMonitor _clientDisconnectMonitor; - - // HResult error codes for normal disconnection detection - private static readonly HashSet NormalDisconnectionHResults = new() - { - unchecked((int)0x800703E3), // ERROR_OPERATION_ABORTED - unchecked((int)0x80070040), // ERROR_NETNAME_DELETED - unchecked((int)0x80072745), // ERROR_CONNECTION_ABORTED - unchecked((int)0x80072746) // ERROR_CONNECTION_RESET - }; + private readonly UnityCliLoopBridgeClientSessionManager _clientSessionManager; private IBridgeTransportListener _transportListener; private CancellationTokenSource _cancellationTokenSource; @@ -92,10 +33,6 @@ public class UnityCliLoopBridgeServer : IUnityCliLoopServerInstance // Guard against concurrent cleanup from ServerLoopAsync finally + external disposal private int _unexpectedExitCleanupStarted = 0; - private readonly ConcurrentDictionary _clientStreams = new(); - private readonly ConcurrentDictionary _clientTasks = new(); - private int _nextClientTaskId; - internal UnityCliLoopBridgeServer( IDomainReloadDetectionService domainReloadDetectionService, JsonRpcRequestProcessor jsonRpcRequestProcessor, @@ -109,12 +46,16 @@ internal UnityCliLoopBridgeServer( _domainReloadDetectionService = domainReloadDetectionService ?? throw new ArgumentNullException(nameof(domainReloadDetectionService)); - _jsonRpcRequestProcessor = jsonRpcRequestProcessor + JsonRpcRequestProcessor validatedJsonRpcRequestProcessor = jsonRpcRequestProcessor ?? throw new ArgumentNullException(nameof(jsonRpcRequestProcessor)); - _heartbeatService = heartbeatService + UnityCliLoopBridgeHeartbeatService validatedHeartbeatService = heartbeatService ?? throw new ArgumentNullException(nameof(heartbeatService)); - _clientDisconnectMonitor = clientDisconnectMonitor + UnityCliLoopBridgeClientDisconnectMonitor validatedClientDisconnectMonitor = clientDisconnectMonitor ?? throw new ArgumentNullException(nameof(clientDisconnectMonitor)); + _clientSessionManager = new UnityCliLoopBridgeClientSessionManager( + validatedJsonRpcRequestProcessor, + validatedHeartbeatService, + validatedClientDisconnectMonitor); } /// @@ -186,7 +127,7 @@ public void StopServer() cancellationTokenSource?.Cancel(); // Explicitly disconnect all connected clients before stopping the server - DisconnectAllClients(); + _clientSessionManager.DisconnectAllClients(); try { @@ -198,7 +139,7 @@ public void StopServer() } Task serverTask = _serverTask; - Task[] clientTasks = GetActiveClientTasks(); + Task[] clientTasks = _clientSessionManager.GetActiveClientTasks(); _serverTask = null; DisposeCancellationSourceAfterServerTaskAsync( serverTask, @@ -256,45 +197,6 @@ private static Task[] BuildShutdownWaitTasks(Task serverTask, Task[] clientTasks return tasks.ToArray(); } - /// - /// Explicitly disconnect all connected clients - /// This ensures CLI clients receive proper close events - /// - private void DisconnectAllClients() - { - if (_clientStreams.IsEmpty) - { - return; - } - - List clientsToRemove = new(); - - foreach (KeyValuePair client in _clientStreams) - { - try - { - if (client.Value != null && client.Value.CanWrite) - { - client.Value.Close(); - } - clientsToRemove.Add(client.Key); - } - catch (Exception ex) - { - VibeLogger.LogWarning( - "client_disconnect_failed", - $"Error disconnecting client {client.Key}: {ex.Message}"); - clientsToRemove.Add(client.Key); // Remove even if disconnect failed - } - } - - // Remove all clients from the connected clients list - foreach (string clientKey in clientsToRemove) - { - _clientStreams.TryRemove(clientKey, out _); - } - } - /// /// StopServer() guards on _isRunning==true, but by the time this runs _isRunning may already /// be false or the normal shutdown path may race with the finally block. @@ -309,7 +211,7 @@ private void CleanupAfterUnexpectedLoopExit() return; } - DisconnectAllClients(); + _clientSessionManager.DisconnectAllClients(); CancellationTokenSource cancellationTokenSource = TakeCancellationTokenSource(); cancellationTokenSource?.Cancel(); @@ -340,7 +242,7 @@ private async Task ServerLoopAsync(CancellationToken cancellationToken) BridgeClientConnection client = await AcceptClientAsync(_transportListener, cancellationToken); if (client != null) { - StartClientHandler(client, cancellationToken); + _clientSessionManager.StartClientHandler(client, cancellationToken); } } catch (ObjectDisposedException) @@ -408,33 +310,6 @@ private async Task ServerLoopAsync(CancellationToken cancellationToken) } } - private void StartClientHandler(BridgeClientConnection client, CancellationToken cancellationToken) - { - int taskId = Interlocked.Increment(ref _nextClientTaskId); - Task clientTask = Task.Run(() => HandleClientAsync(client, cancellationToken)); - _clientTasks.TryAdd(taskId, clientTask); - clientTask.ContinueWith( - task => - { - _clientTasks.TryRemove(taskId, out _); - if (task.IsFaulted && task.Exception != null) - { - // HandleClientAsync catches expected failures itself, so a faulted task is - // an anomaly worth surfacing in the console for non-debug installs. - Debug.LogError( - $"[{UnityCliLoopConstants.PROJECT_NAME}] Client handler task faulted: {task.Exception.GetBaseException()}"); - } - }, - CancellationToken.None, - TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); - } - - private Task[] GetActiveClientTasks() - { - return _clientTasks.Values.ToArray(); - } - private async Task AcceptClientAsync(IBridgeTransportListener listener, CancellationToken cancellationToken) { try @@ -458,246 +333,6 @@ private async Task AcceptClientAsync(IBridgeTransportLis } } - /// - /// Handles communication with the client using Content-Length framing. - /// - private async Task HandleClientAsync(BridgeClientConnection client, CancellationToken cancellationToken) - { - string clientKey = client.Endpoint; - - // Initialize new components for Content-Length framing - DynamicBufferManager bufferManager = null; - MessageReassembler messageReassembler = null; - - try - { - using (client) - using (Stream stream = client.Stream) - { - - // Check for existing connection from same endpoint and close it - if (_clientStreams.TryRemove(clientKey, out Stream existingStream)) - { - existingStream?.Close(); - } - - _clientStreams.TryAdd(clientKey, stream); - - // Initialize new framing components - bufferManager = new DynamicBufferManager(); - messageReassembler = new MessageReassembler(bufferManager); - - // Not disposed deliberately: a heartbeat writer could still be releasing it - // during teardown, and an undisposed SemaphoreSlim holds no unmanaged state. - SemaphoreSlim streamWriteLock = new(1, 1); - - // Start with initial buffer size - byte[] buffer = bufferManager.GetBuffer(BufferConfig.INITIAL_BUFFER_SIZE); - - while (!cancellationToken.IsCancellationRequested) - { - int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken); - - if (bytesRead == 0) - { - break; // Client disconnected. - } - - // Add received data to message reassembler - messageReassembler.AddData(buffer, bytesRead); - - // Extract any complete messages - string[] completeJsonMessages = messageReassembler.ExtractCompleteMessages(); - - foreach (string requestJson in completeJsonMessages) - { - if (string.IsNullOrWhiteSpace(requestJson)) continue; - - await ProcessRequestFrameAsync(client, stream, streamWriteLock, requestJson, cancellationToken); - } - - // Why: false means the reassembler was disposed and can no longer make - // progress; closing the connection lets the CLI observe the disconnect - // immediately instead of waiting silently for its own timeout. - // (Corrupted framing state throws from ValidateState and is handled below.) - if (!messageReassembler.ValidateState()) - { - break; - } - } - } - } - catch (ThreadAbortException) - { - // Treat as normal behavior if a domain reload is in progress. - // No need to log thread aborts during domain reload - } - catch (OperationCanceledException) - { - // Normal cancellation during server shutdown or domain reload - // No logging needed as this is expected behavior during Unity Editor operations - } - catch (IOException ex) - { - // I/O errors are usually normal disconnections - only log as info instead of warning - if (IsNormalDisconnectionException(ex)) - { - // Log normal disconnections as info level - } - } - catch (Exception ex) - { - // Expected failures (cancellation, reload aborts, normal disconnects) are filtered - // above, so anything reaching here means a CLI session died abnormally and the - // console must say so even on installs where VibeLogger is compiled out. - Debug.LogError( - $"[{UnityCliLoopConstants.PROJECT_NAME}] Client session for {clientKey} failed: {ex}"); - } - finally - { - // Dispose of framing components - try - { - messageReassembler?.Dispose(); - bufferManager?.Dispose(); - } - catch (Exception ex) - { - VibeLogger.LogWarning( - "client_dispose_failed", - $"Error during client disposal: {ex.Message}"); - } - - _clientStreams.TryRemove(clientKey, out _); - - client.Dispose(); - } - } - - private async Task ProcessRequestFrameAsync( - BridgeClientConnection client, - Stream stream, - SemaphoreSlim streamWriteLock, - string requestJson, - CancellationToken serverCancellationToken) - { - using (CancellationTokenSource requestCancellationTokenSource = - CancellationTokenSource.CreateLinkedTokenSource(serverCancellationToken)) - { - Task clientDisconnectMonitorTask = null; - Task heartbeatTask = null; - CancellationTokenSource heartbeatCancellationSource = null; - try - { - string responseJson = await _jsonRpcRequestProcessor.ProcessRequestWithEarlyResponseAsync( - requestJson, - requestCancellationTokenSource.Token, - async (responseJsonValue, cancelOnClientDisconnect, createHeartbeatJson) => - { - await UnityCliLoopBridgeResponseWriter.WriteJsonResponseLockedAsync( - stream, streamWriteLock, responseJsonValue, serverCancellationToken); - - if (createHeartbeatJson != null) - { - heartbeatCancellationSource = CancellationTokenSource.CreateLinkedTokenSource( - requestCancellationTokenSource.Token); - // Why: the heartbeat token must govern the write as well; with the - // server token an in-flight write could ignore StopHeartbeatsAsync - // and stall the final response behind a slow client. - CancellationToken heartbeatToken = heartbeatCancellationSource.Token; - heartbeatTask = _heartbeatService.SendHeartbeatsAsync( - createHeartbeatJson, - heartbeatJson => UnityCliLoopBridgeResponseWriter.WriteJsonResponseLockedAsync( - stream, streamWriteLock, heartbeatJson, heartbeatToken), - TimeSpan.FromSeconds(UnityCliLoopServerConfig.HEARTBEAT_INTERVAL_SECONDS), - heartbeatToken); - } - - if (!cancelOnClientDisconnect) - { - return; - } - - clientDisconnectMonitorTask = - _clientDisconnectMonitor.MonitorClientDisconnectAsync( - client, - requestCancellationTokenSource); - }); - - // Stop heartbeats before the final response so no heartbeat frame can be - // queued after the response the CLI stops reading at. - await _heartbeatService.StopHeartbeatsAsync(heartbeatTask, heartbeatCancellationSource); - heartbeatTask = null; - - await UnityCliLoopBridgeResponseWriter.WriteJsonResponseLockedAsync( - stream, streamWriteLock, responseJson, serverCancellationToken); - } - finally - { - await _heartbeatService.StopHeartbeatsAsync(heartbeatTask, heartbeatCancellationSource); - heartbeatCancellationSource?.Dispose(); - await _clientDisconnectMonitor.StopClientDisconnectMonitorAsync( - clientDisconnectMonitorTask, - requestCancellationTokenSource); - } - } - } - - /// - /// Determines if the given exception represents a normal client disconnection. - /// - /// The exception to evaluate - /// True if the exception represents a normal disconnection, false otherwise - private static bool IsNormalDisconnectionException(Exception ex) - { - switch (ex) - { - case SocketException sockEx: - return sockEx.SocketErrorCode is SocketError.ConnectionReset or - SocketError.ConnectionAborted or - SocketError.OperationAborted or - SocketError.Shutdown or - SocketError.NotConnected; - - case ObjectDisposedException: - return true; - - case IOException ioEx when ioEx.InnerException is SocketException innerSockEx: - return innerSockEx.SocketErrorCode is SocketError.ConnectionReset or - SocketError.ConnectionAborted or - SocketError.OperationAborted or - SocketError.Shutdown or - SocketError.NotConnected; - - case IOException ioEx: - // Check HResult codes for common disconnection scenarios - return NormalDisconnectionHResults.Contains(ioEx.HResult) || - IsNormalDisconnectionByInnerException(ioEx); - - default: - return false; - } - } - - /// - /// Recursively checks inner exceptions for normal disconnection scenarios - /// - /// The exception to check - /// True if any inner exception indicates a normal disconnection - private static bool IsNormalDisconnectionByInnerException(Exception ex) - { - Exception innerEx = ex.InnerException; - while (innerEx != null) - { - if (IsNormalDisconnectionException(innerEx)) - { - return true; - } - innerEx = innerEx.InnerException; - } - return false; - } - /// /// Releases resources. /// @@ -710,4 +345,4 @@ public void Dispose() _serverTask = null; } } -} +} diff --git a/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServerInstanceFactory.cs b/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServerInstanceFactory.cs new file mode 100644 index 0000000000..38319074d6 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServerInstanceFactory.cs @@ -0,0 +1,53 @@ +using System; + +using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.Domain; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Creates Unity CLI Loop Bridge Server Instance instances with the dependencies required by this module. + /// + public sealed class UnityCliLoopBridgeServerInstanceFactory : + IUnityCliLoopServerInstanceFactory, + IUnityCliLoopServerLifecycleSource + { + public event Action ServerLoopExited; + private readonly IDomainReloadDetectionService _domainReloadDetectionService; + private readonly UnityCliLoopToolRegistrarService _toolRegistrarService; + + internal UnityCliLoopBridgeServerInstanceFactory( + IDomainReloadDetectionService domainReloadDetectionService, + UnityCliLoopToolRegistrarService toolRegistrarService) + { + System.Diagnostics.Debug.Assert(domainReloadDetectionService != null, "domainReloadDetectionService must not be null"); + System.Diagnostics.Debug.Assert(toolRegistrarService != null, "toolRegistrarService must not be null"); + + _domainReloadDetectionService = domainReloadDetectionService + ?? throw new ArgumentNullException(nameof(domainReloadDetectionService)); + _toolRegistrarService = toolRegistrarService + ?? throw new ArgumentNullException(nameof(toolRegistrarService)); + } + + public IUnityCliLoopServerInstance Create() + { + UnityCliLoopBridgeHeartbeatService heartbeatService = new(); + UnityCliLoopBridgeClientDisconnectMonitor clientDisconnectMonitor = new(); + UnityCliLoopExecutionRouter executionRouter = new(_toolRegistrarService); + JsonRpcRequestProcessor jsonRpcRequestProcessor = new(executionRouter); + UnityCliLoopBridgeServer server = new( + _domainReloadDetectionService, + jsonRpcRequestProcessor, + heartbeatService, + clientDisconnectMonitor); + server.ServerLoopExited += NotifyServerLoopExited; + + return server; + } + + private void NotifyServerLoopExited() + { + ServerLoopExited?.Invoke(); + } + } +} diff --git a/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServerInstanceFactory.cs.meta b/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServerInstanceFactory.cs.meta new file mode 100644 index 0000000000..ff040005b4 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServerInstanceFactory.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 620d1d36ae77447990654fa185c55822 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: