chore(unity): 固定Unity CLI Loop本地包并移除UnitySkills依赖

This commit is contained in:
Liubing\LB
2026-09-28 18:14:59 +08:00
parent 0eb43b2b40
commit c4ae02def9
952 changed files with 70137 additions and 11 deletions

View File

@@ -0,0 +1,337 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Manages dynamic buffer allocation and recycling for TCP communication.
/// Provides memory-efficient buffer management with automatic sizing and cleanup.
/// </summary>
public class DynamicBufferManager : IDisposable
{
private readonly ConcurrentQueue<byte[]> _bufferPool = new();
private readonly object _lockObject = new();
private bool _disposed = false;
// Statistics for monitoring
private int _totalBuffersCreated = 0;
private int _totalBuffersReused = 0;
private int _currentPoolSize = 0;
/// <summary>
/// Maximum number of buffers to keep in the pool to prevent memory bloat.
/// </summary>
private const int MAX_POOL_SIZE = 10;
/// <summary>
/// Gets a buffer of at least the specified size.
/// Reuses existing buffers when possible for memory efficiency.
/// </summary>
/// <param name="requiredSize">The minimum required buffer size</param>
/// <returns>A byte array of at least the required size</returns>
public byte[] GetBuffer(int requiredSize)
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(DynamicBufferManager));
}
if (requiredSize <= 0)
{
throw new ArgumentException("Required size must be positive", nameof(requiredSize));
}
if (requiredSize > BufferConfig.MAX_BUFFER_SIZE)
{
throw new ArgumentException($"Required size {requiredSize} exceeds maximum buffer size {BufferConfig.MAX_BUFFER_SIZE}", nameof(requiredSize));
}
// Try to reuse an existing buffer from the pool
byte[] suitableBuffer = null;
var tempBuffers = new List<byte[]>();
// Look for a suitable buffer - stop when found
while (_bufferPool.TryDequeue(out byte[] pooledBuffer))
{
lock (_lockObject)
{
_currentPoolSize--;
}
if (pooledBuffer.Length >= requiredSize && suitableBuffer == null)
{
suitableBuffer = pooledBuffer;
break; // Stop searching once we find a suitable buffer
}
else
{
// Put back buffers that are too small
tempBuffers.Add(pooledBuffer);
}
}
// Put back the buffers that were too small
foreach (var buffer in tempBuffers)
{
lock (_lockObject)
{
if (_currentPoolSize < MAX_POOL_SIZE)
{
_bufferPool.Enqueue(buffer);
_currentPoolSize++;
}
}
}
if (suitableBuffer != null)
{
_totalBuffersReused++;
return suitableBuffer;
}
// No suitable buffer found, create a new one
int newBufferSize = CalculateOptimalBufferSize(requiredSize);
byte[] newBuffer = new byte[newBufferSize];
_totalBuffersCreated++;
return newBuffer;
}
/// <summary>
/// Returns a buffer to the pool for potential reuse.
/// Only keeps buffers within reasonable size limits to prevent memory bloat.
/// </summary>
/// <param name="buffer">The buffer to return to the pool</param>
public void ReturnBuffer(byte[] buffer)
{
if (_disposed || buffer == null)
{
return;
}
// Only pool buffers within reasonable size limits
if (buffer.Length < BufferConfig.MIN_BUFFER_SIZE || buffer.Length > BufferConfig.MAX_BUFFER_SIZE)
{
return;
}
lock (_lockObject)
{
// Limit pool size to prevent memory bloat
if (_currentPoolSize >= MAX_POOL_SIZE)
{
return;
}
_currentPoolSize++;
}
_bufferPool.Enqueue(buffer);
}
/// <summary>
/// Resizes a buffer to accommodate new data.
/// Creates a new buffer and copies existing data if necessary.
/// </summary>
/// <param name="buffer">Reference to the current buffer</param>
/// <param name="currentDataLength">Length of valid data in the current buffer</param>
/// <param name="newSize">The new required buffer size</param>
public void ResizeBuffer(ref byte[] buffer, int currentDataLength, int newSize)
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(DynamicBufferManager));
}
if (buffer == null)
{
throw new ArgumentNullException(nameof(buffer));
}
if (currentDataLength < 0)
{
throw new ArgumentException("Current data length cannot be negative", nameof(currentDataLength));
}
if (currentDataLength > buffer.Length)
{
throw new ArgumentException("Current data length exceeds buffer size", nameof(currentDataLength));
}
if (newSize <= buffer.Length)
{
// Current buffer is already large enough
return;
}
if (newSize > BufferConfig.MAX_BUFFER_SIZE)
{
throw new ArgumentException($"New size {newSize} exceeds maximum buffer size {BufferConfig.MAX_BUFFER_SIZE}", nameof(newSize));
}
// Get a new larger buffer
byte[] oldBuffer = buffer;
buffer = GetBuffer(newSize);
// Copy existing data to the new buffer
if (currentDataLength > 0)
{
Array.Copy(oldBuffer, 0, buffer, 0, currentDataLength);
}
// Return the old buffer to the pool
ReturnBuffer(oldBuffer);
}
/// <summary>
/// Calculates the optimal buffer size for the given required size.
/// Uses growth factor to minimize frequent reallocations.
/// </summary>
/// <param name="requiredSize">The minimum required size</param>
/// <returns>The optimal buffer size</returns>
private int CalculateOptimalBufferSize(int requiredSize)
{
// Start with initial buffer size
int optimalSize = BufferConfig.INITIAL_BUFFER_SIZE;
// Grow by factor until we meet the requirement
while (optimalSize < requiredSize)
{
optimalSize *= BufferConfig.BUFFER_GROWTH_FACTOR;
// Cap at maximum buffer size
if (optimalSize > BufferConfig.MAX_BUFFER_SIZE)
{
optimalSize = BufferConfig.MAX_BUFFER_SIZE;
break;
}
}
// Ensure we meet the minimum requirement
return Math.Max(optimalSize, requiredSize);
}
/// <summary>
/// Gets buffer management statistics for monitoring and debugging.
/// </summary>
/// <returns>Statistics about buffer usage</returns>
public BufferManagerStats GetStats()
{
lock (_lockObject)
{
return new BufferManagerStats
{
TotalBuffersCreated = _totalBuffersCreated,
TotalBuffersReused = _totalBuffersReused,
CurrentPoolSize = _currentPoolSize,
MaxPoolSize = MAX_POOL_SIZE,
ReuseRate = _totalBuffersCreated > 0 ? (double)_totalBuffersReused / (_totalBuffersCreated + _totalBuffersReused) * 100 : 0
};
}
}
/// <summary>
/// Clears the buffer pool and releases all pooled buffers.
/// Useful for memory cleanup during low-usage periods.
/// </summary>
public void ClearPool()
{
if (_disposed)
{
return;
}
lock (_lockObject)
{
int buffersReleased = 0;
while (_bufferPool.TryDequeue(out _))
{
buffersReleased++;
}
_currentPoolSize = 0;
}
}
/// <summary>
/// Validates buffer parameters for safety checks.
/// </summary>
/// <param name="buffer">The buffer to validate</param>
/// <param name="offset">The offset within the buffer</param>
/// <param name="length">The length of data</param>
/// <returns>True if parameters are valid, false otherwise</returns>
public static bool ValidateBufferParameters(byte[] buffer, int offset, int length)
{
if (buffer == null)
{
return false;
}
if (offset < 0 || offset >= buffer.Length)
{
return false;
}
if (length < 0 || offset + length > buffer.Length)
{
return false;
}
return true;
}
/// <summary>
/// Releases all resources used by the DynamicBufferManager.
/// </summary>
public void Dispose()
{
if (!_disposed)
{
ClearPool();
_disposed = true;
}
}
}
/// <summary>
/// Statistics about buffer manager performance and usage.
/// </summary>
public class BufferManagerStats
{
/// <summary>
/// Total number of new buffers created.
/// </summary>
public int TotalBuffersCreated { get; set; }
/// <summary>
/// Total number of buffers reused from the pool.
/// </summary>
public int TotalBuffersReused { get; set; }
/// <summary>
/// Current number of buffers in the pool.
/// </summary>
public int CurrentPoolSize { get; set; }
/// <summary>
/// Maximum allowed pool size.
/// </summary>
public int MaxPoolSize { get; set; }
/// <summary>
/// Buffer reuse rate as a percentage.
/// </summary>
public double ReuseRate { get; set; }
/// <summary>
/// Returns a formatted string representation of the statistics.
/// </summary>
public override string ToString()
{
return $"BufferManager Stats: Created={TotalBuffersCreated}, Reused={TotalBuffersReused}, " +
$"PoolSize={CurrentPoolSize}/{MaxPoolSize}, ReuseRate={ReuseRate:F1}%";
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1f086a0e0465f472c8d4921c21db1149
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,233 @@
using System;
using System.Text;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Parses Content-Length framed messages for JSON-RPC 2.0 communication.
/// Handles HTTP-style headers with format: "Content-Length: <n>\r\n\r\n<json_content>"
/// </summary>
public class FrameParser
{
private const string CONTENT_LENGTH_HEADER = "Content-Length:";
private const string HEADER_SEPARATOR = "\r\n\r\n";
private const string LINE_SEPARATOR = "\r\n";
/// <summary>
/// Attempts to parse a Content-Length framed message from the buffer.
/// </summary>
/// <param name="buffer">The buffer containing the data</param>
/// <param name="length">The length of valid data in the buffer</param>
/// <param name="contentLength">The parsed content length, or -1 if parsing failed</param>
/// <param name="headerLength">The total length of the header including separators, or -1 if parsing failed</param>
/// <returns>True if the header was successfully parsed, false otherwise</returns>
public bool TryParseFrame(byte[] buffer, int length, out int contentLength, out int headerLength)
{
contentLength = -1;
headerLength = -1;
if (buffer == null || length <= 0)
{
return false;
}
// Find the header separator without converting entire buffer to string
byte[] separatorBytes = Encoding.UTF8.GetBytes(HEADER_SEPARATOR);
int separatorIndex = FindByteSequence(buffer, length, separatorBytes);
if (separatorIndex == -1)
{
// Header not complete yet
return false;
}
// Extract header section only - convert only the header part to string
string headerSection = Encoding.UTF8.GetString(buffer, 0, separatorIndex);
int tempHeaderLength = separatorIndex + separatorBytes.Length;
// Parse Content-Length from header
bool parseResult = TryParseContentLength(headerSection, out contentLength);
// Only set headerLength if parsing was successful
if (parseResult)
{
headerLength = tempHeaderLength;
return true;
}
else
{
// Reset values on failure
contentLength = -1;
headerLength = -1;
return false;
}
}
/// <summary>
/// Checks if a complete frame is available in the buffer.
/// </summary>
/// <param name="buffer">The buffer containing the data</param>
/// <param name="length">The length of valid data in the buffer</param>
/// <param name="contentLength">The expected content length</param>
/// <param name="headerLength">The header length including separators</param>
/// <returns>True if the complete frame is available, false otherwise</returns>
public bool IsCompleteFrame(byte[] buffer, int length, int contentLength, int headerLength)
{
if (buffer == null || length <= 0 || contentLength < 0 || headerLength < 0)
{
return false;
}
int expectedTotalLength = headerLength + contentLength;
return length >= expectedTotalLength;
}
/// <summary>
/// Extracts the JSON content from a complete frame.
/// </summary>
/// <param name="buffer">The buffer containing the complete frame</param>
/// <param name="contentLength">The content length</param>
/// <param name="headerLength">The header length including separators</param>
/// <returns>The JSON content as a string, or null if extraction failed</returns>
public string ExtractJsonContent(byte[] buffer, int contentLength, int headerLength)
{
if (buffer == null || contentLength <= 0 || headerLength < 0)
{
return null;
}
// Extract JSON content after the header - let encoding exceptions bubble up
return Encoding.UTF8.GetString(buffer, headerLength, contentLength);
}
/// <summary>
/// Validates that the Content-Length value is within acceptable limits.
/// </summary>
/// <param name="contentLength">The content length to validate</param>
/// <returns>True if the content length is valid, false otherwise</returns>
public bool IsValidContentLength(int contentLength)
{
return contentLength >= 0 && contentLength <= BufferConfig.MAX_MESSAGE_SIZE;
}
/// <summary>
/// Parses the Content-Length value from the header section.
/// </summary>
/// <param name="headerSection">The header section string</param>
/// <param name="contentLength">The parsed content length</param>
/// <returns>True if parsing was successful, false otherwise</returns>
private bool TryParseContentLength(string headerSection, out int contentLength)
{
contentLength = -1;
if (string.IsNullOrEmpty(headerSection))
{
return false;
}
// Split header into lines
string[] lines = headerSection.Split(new[] { LINE_SEPARATOR }, StringSplitOptions.RemoveEmptyEntries);
foreach (string line in lines)
{
string trimmedLine = line.Trim();
// Check if this line contains Content-Length header
if (trimmedLine.StartsWith(CONTENT_LENGTH_HEADER, StringComparison.OrdinalIgnoreCase))
{
// Extract the value after the colon
int colonIndex = trimmedLine.IndexOf(':');
if (colonIndex == -1 || colonIndex >= trimmedLine.Length - 1)
{
continue;
}
string valueString = trimmedLine.Substring(colonIndex + 1).Trim();
// Try to parse the integer value
if (int.TryParse(valueString, out int parsedValue))
{
if (IsValidContentLength(parsedValue))
{
contentLength = parsedValue;
return true;
}
else
{
// Return false but don't set headerLength to -1 in calling method
contentLength = -1;
return false;
}
}
else
{
// Return false but don't set headerLength to -1 in calling method
contentLength = -1;
return false;
}
}
}
return false;
}
/// <summary>
/// Finds the first occurrence of a byte sequence in a buffer.
/// </summary>
/// <param name="buffer">The buffer to search in</param>
/// <param name="bufferLength">The length of valid data in the buffer</param>
/// <param name="sequence">The byte sequence to find</param>
/// <returns>The index of the first occurrence, or -1 if not found</returns>
private static int FindByteSequence(byte[] buffer, int bufferLength, byte[] sequence)
{
if (buffer == null || sequence == null || sequence.Length == 0 || bufferLength < sequence.Length)
return -1;
for (int i = 0; i <= bufferLength - sequence.Length; i++)
{
bool found = true;
for (int j = 0; j < sequence.Length; j++)
{
if (buffer[i + j] != sequence[j])
{
found = false;
break;
}
}
if (found)
return i;
}
return -1;
}
}
/// <summary>
/// Configuration constants for buffer management and frame parsing.
/// </summary>
public static class BufferConfig
{
/// <summary>
/// Initial buffer size for new connections.
/// </summary>
public const int INITIAL_BUFFER_SIZE = 4096;
/// <summary>
/// Maximum buffer size to prevent memory exhaustion.
/// </summary>
public const int MAX_BUFFER_SIZE = 1024 * 1024; // 1MB
/// <summary>
/// Maximum allowed message size.
/// </summary>
public const int MAX_MESSAGE_SIZE = 1024 * 1024; // 1MB
/// <summary>
/// Buffer growth factor when resizing is needed.
/// </summary>
public const int BUFFER_GROWTH_FACTOR = 2;
/// <summary>
/// Minimum buffer size to maintain.
/// </summary>
public const int MIN_BUFFER_SIZE = 1024;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 875d88de254b6418bb2d15b0f1dabb4b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,968 @@
using System;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace io.github.hatayama.uLoopMCP
{
// Related classes:
// - McpServerController: Manages the lifecycle of this server.
// - UnityCommandExecutor: Executes commands received from clients.
// - JsonRpcProcessor: Handles JSON-RPC 2.0 message processing.
/// <summary>
/// Represents a connected client
/// </summary>
public class ConnectedClient
{
public readonly string Endpoint;
public readonly string ClientName;
public readonly DateTime ConnectedAt;
public readonly int Port;
public readonly NetworkStream Stream;
public ConnectedClient(string endpoint, NetworkStream stream, int port, string clientName = McpConstants.UNKNOWN_CLIENT_NAME)
{
Endpoint = endpoint;
Stream = stream; // Allow null stream for UI display purposes
ClientName = clientName;
ConnectedAt = DateTime.Now;
Port = port;
}
// Private constructor for WithClientName to preserve ConnectedAt
private ConnectedClient(string endpoint, NetworkStream stream, int port, string clientName, DateTime connectedAt)
{
Endpoint = endpoint;
Stream = stream; // Allow null stream for UI display purposes
ClientName = clientName;
ConnectedAt = connectedAt;
Port = port;
}
public ConnectedClient WithClientName(string clientName)
{
return new ConnectedClient(Endpoint, Stream, Port, clientName, ConnectedAt);
}
}
/// <summary>
/// Unity MCP Bridge TCP/IP Server.
/// Accepts connections from the TypeScript MCP Server and handles JSON-RPC 2.0 communication.
/// </summary>
public class McpBridgeServer : IDisposable
{
// Note: Domain reload progress is now tracked via McpSessionManager
// Events for server lifecycle notifications
public static event System.Action OnServerStopping;
public static event System.Action OnServerStarted;
// Events for individual tool management
public static event System.Action<ConnectedClient> OnToolConnected;
public static event System.Action<string> OnToolDisconnected;
public static event System.Action OnAllToolsCleared;
// Fired from thread pool when ServerLoopAsync exits while _isRunning is still true.
// Subscribers must marshal to main thread before accessing Unity APIs.
public static event System.Action OnServerLoopExited;
// HResult error codes for normal disconnection detection
private static readonly HashSet<int> 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 TcpListener _tcpListener;
private CancellationTokenSource _cancellationTokenSource;
private Task _serverTask;
private readonly ConcurrentDictionary<int, Task> _clientTasks = new();
// Read from thread pool (ServerLoopAsync), written from main thread (StopServer)
private volatile bool _isRunning = false;
// Guard against concurrent cleanup from ServerLoopAsync finally + external disposal
private int _unexpectedExitCleanupStarted = 0;
// Client management for broadcasting notifications
private readonly ConcurrentDictionary<string, ConnectedClient> _connectedClients = new();
/// <summary>
/// Whether the server is running.
/// </summary>
public bool IsRunning => _isRunning;
/// <summary>
/// The server's port number.
/// </summary>
public int Port { get; private set; } = McpEditorSettings.GetCustomPort();
/// <summary>
/// Event on client connection.
/// </summary>
public event Action<string> OnClientConnected;
/// <summary>
/// Event on client disconnection.
/// </summary>
public event Action<string> OnClientDisconnected;
/// <summary>
/// Event on error.
/// </summary>
public event Action<string> OnError;
/// <summary>
/// Generate unique client key using Endpoint
/// </summary>
private string GenerateClientKey(string endpoint)
{
// Use endpoint as unique identifier
return endpoint;
}
/// <summary>
/// Get list of connected clients sorted by name
/// </summary>
public IReadOnlyCollection<ConnectedClient> GetConnectedClients()
{
return _connectedClients.Values.OrderBy(client => client.ClientName).ToArray();
}
/// <summary>
/// Update client name for a connected client
/// </summary>
public void UpdateClientName(string clientEndpoint, string clientName)
{
// Find client by endpoint (backward compatibility)
ConnectedClient targetClient = _connectedClients.Values
.FirstOrDefault(c => c.Endpoint == clientEndpoint);
if (targetClient != null)
{
string clientKey = GenerateClientKey(targetClient.Endpoint);
ConnectedClient updatedClient = targetClient.WithClientName(clientName);
bool updateResult = _connectedClients.TryUpdate(clientKey, updatedClient, targetClient);
// Clear reconnecting flags when client name is successfully set (client is now fully connected)
if (updateResult && clientName != McpConstants.UNKNOWN_CLIENT_NAME)
{
McpServerController.ClearReconnectingFlag();
// Notify tool connected
OnToolConnected?.Invoke(updatedClient);
}
}
}
/// <summary>
/// Checks if the specified port is in use.
/// Delegates to NetworkUtility for consistent port checking behavior.
/// </summary>
/// <param name="port">The port number to check.</param>
/// <returns>True if the port is in use.</returns>
public static bool IsPortInUse(int port)
{
return NetworkUtility.IsPortInUse(port);
}
/// <summary>
/// Starts the server.
/// </summary>
/// <param name="port">
/// The port number to bind to. Use -1 to fall back to the saved custom port
/// from <see cref="McpEditorSettings.GetCustomPort"/>. Defaults to -1.
/// </param>
public void StartServer(int port = -1, bool clearServerStartingLockWhenReady = true)
{
if (_isRunning)
{
return;
}
Port = port == -1 ? McpEditorSettings.GetCustomPort() : port;
_cancellationTokenSource = new CancellationTokenSource();
_unexpectedExitCleanupStarted = 0;
try
{
_tcpListener = new TcpListener(IPAddress.Loopback, Port);
_tcpListener.Start();
_isRunning = true;
TcpListener tcpListener = _tcpListener;
CancellationToken cancellationToken = _cancellationTokenSource.Token;
_serverTask = Task.Run(() => ServerLoopAsync(tcpListener, cancellationToken));
// Safety net: log if the server task faults unexpectedly.
// Primary detection is in ServerLoopAsync's finally block; this catches unhandled exceptions in Task.Run itself.
_serverTask.ContinueWith(task =>
{
if (task.IsFaulted)
{
VibeLogger.LogError(
"server_task_faulted",
$"Server task faulted unexpectedly: {task.Exception?.GetBaseException().Message}",
new { exceptionType = task.Exception?.GetBaseException().GetType().Name }
);
}
}, TaskScheduler.Default);
// Server is now ready to accept connections - clean up compilation/reload locks.
CompilationLockService.DeleteLockFile();
DomainReloadDetectionService.DeleteLockFile();
if (clearServerStartingLockWhenReady)
{
ServerStartingLockService.DeleteLockFile();
}
// Notify that server has started
OnServerStarted?.Invoke();
}
catch (SocketException ex) when (ex.SocketErrorCode == SocketError.AddressAlreadyInUse)
{
_isRunning = false;
string errorMessage = $"Port {Port} is already in use. Please choose a different port.";
OnError?.Invoke(errorMessage);
throw new InvalidOperationException(errorMessage, ex);
}
catch (Exception ex)
{
_isRunning = false;
string errorMessage = $"Failed to start MCP Server: {ex.Message}";
OnError?.Invoke(errorMessage);
throw;
}
}
/// <summary>
/// Stops the server.
/// </summary>
public void StopServer()
{
StopServerCore(ServerStopMode.Normal);
}
/// <summary>
/// Domain reload can suspend editor-thread continuations before the server task observes shutdown.
/// </summary>
public void StopServerBeforeDomainReload()
{
StopServerCore(ServerStopMode.BeforeDomainReload);
}
private void StopServerCore(ServerStopMode mode)
{
bool wasRunning = _isRunning;
CancellationTokenSource cancellationTokenSource = Interlocked.Exchange(ref _cancellationTokenSource, null);
TcpListener tcpListener = Interlocked.Exchange(ref _tcpListener, null);
Task serverTask = mode == ServerStopMode.BeforeDomainReload
? _serverTask
: Interlocked.Exchange(ref _serverTask, null);
if (!wasRunning &&
cancellationTokenSource == null &&
tcpListener == null &&
serverTask == null)
{
return;
}
if (wasRunning && mode != ServerStopMode.UnexpectedLoopExit)
{
ServerShutdownReason shutdownReason = McpEditorSettings.GetIsDomainReloadInProgress()
? ServerShutdownReason.DomainReload
: ServerShutdownReason.EditorQuit;
SendShutdownNotification(shutdownReason);
OnServerStopping?.Invoke();
}
_isRunning = false;
DisconnectAllClientsCore(notifyLifecycleEvents: mode != ServerStopMode.UnexpectedLoopExit);
cancellationTokenSource?.Cancel();
tcpListener?.Stop();
if (mode == ServerStopMode.BeforeDomainReload)
{
return;
}
if (mode == ServerStopMode.UnexpectedLoopExit)
{
cancellationTokenSource?.Dispose();
return;
}
serverTask?.Wait(TimeSpan.FromSeconds(McpServerConfig.SHUTDOWN_TIMEOUT_SECONDS));
DisposeCancellationTokenSourceAfterClientTasks(cancellationTokenSource);
}
private void DisposeCancellationTokenSourceAfterClientTasks(
CancellationTokenSource cancellationTokenSource)
{
Task[] clientTasks = _clientTasks.Values.ToArray();
if (clientTasks.Length == 0)
{
cancellationTokenSource?.Dispose();
return;
}
if (MainThreadSwitcher.IsMainThread)
{
_ = ObserveClientTasksThenDisposeCancellationTokenSourceAsync(
clientTasks,
cancellationTokenSource);
return;
}
WaitForClientTasksToComplete(clientTasks);
cancellationTokenSource?.Dispose();
}
private static void WaitForClientTasksToComplete(Task[] clientTasks)
{
Task completionTask = CreateObservedClientTaskCompletionTask(clientTasks);
bool completed = completionTask.Wait(TimeSpan.FromSeconds(McpServerConfig.SHUTDOWN_TIMEOUT_SECONDS));
if (!completed)
{
LogClientTaskShutdownTimeout(clientTasks.Length);
}
}
private static async Task ObserveClientTasksThenDisposeCancellationTokenSourceAsync(
Task[] clientTasks,
CancellationTokenSource cancellationTokenSource)
{
try
{
Task completionTask = CreateObservedClientTaskCompletionTask(clientTasks);
Task timeoutTask = Task.Delay(TimeSpan.FromSeconds(McpServerConfig.SHUTDOWN_TIMEOUT_SECONDS));
Task finishedTask = await Task.WhenAny(completionTask, timeoutTask);
if (!ReferenceEquals(finishedTask, completionTask))
{
LogClientTaskShutdownTimeout(clientTasks.Length);
}
}
finally
{
cancellationTokenSource?.Dispose();
}
}
private static Task CreateObservedClientTaskCompletionTask(Task[] clientTasks)
{
return Task.WhenAll(clientTasks).ContinueWith(task =>
{
if (task.IsFaulted)
{
_ = task.Exception;
}
}, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
}
private static void LogClientTaskShutdownTimeout(int activeClientTasks)
{
VibeLogger.LogWarning(
"client_tasks_shutdown_timeout",
"Timed out waiting for client handlers to finish during normal server shutdown.",
new { activeClientTasks }
);
}
/// <summary>
/// Explicitly disconnect all connected clients
/// This ensures TypeScript clients receive proper close events
/// </summary>
private void DisconnectAllClients()
{
DisconnectAllClientsCore(notifyLifecycleEvents: true);
}
/// <summary>
/// OnAllToolsCleared subscribers (UI components) require the main thread.
/// Background-thread callers (CleanupAfterUnexpectedLoopExit) must suppress
/// lifecycle events to avoid cross-thread Unity API violations.
/// </summary>
private void DisconnectAllClientsCore(bool notifyLifecycleEvents)
{
if (_connectedClients.IsEmpty)
{
return;
}
List<string> clientsToRemove = new List<string>();
foreach (KeyValuePair<string, ConnectedClient> client in _connectedClients)
{
try
{
// Close the NetworkStream to send proper close event to TypeScript client
if (client.Value.Stream != null && client.Value.Stream.CanWrite)
{
client.Value.Stream.Close();
}
clientsToRemove.Add(client.Key);
}
catch (Exception ex)
{
OnError?.Invoke($"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)
{
_connectedClients.TryRemove(clientKey, out _);
}
// Lifecycle events require main thread — only fire when explicitly requested
if (notifyLifecycleEvents && !McpEditorSettings.GetIsDomainReloadInProgress())
{
OnAllToolsCleared?.Invoke();
}
}
/// <summary>
/// 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.
/// A separate cleanup path that skips the _isRunning guard is needed.
/// Lifecycle events are deferred to OnServerLoopExited → EditorApplication.delayCall
/// because this runs on the thread pool where Unity APIs are unsafe.
/// </summary>
private void CleanupAfterUnexpectedLoopExit()
{
if (Interlocked.Exchange(ref _unexpectedExitCleanupStarted, 1) != 0)
{
return;
}
StopServerCore(ServerStopMode.UnexpectedLoopExit);
}
/// <summary>
/// The server's main loop.
/// </summary>
private async Task ServerLoopAsync(TcpListener listener, CancellationToken cancellationToken)
{
System.Diagnostics.Debug.Assert(listener != null, "TcpListener must be captured before starting the server loop.");
try
{
while (!cancellationToken.IsCancellationRequested && _isRunning)
{
try
{
TcpClient client = await AcceptTcpClientAsync(listener, cancellationToken);
if (client != null)
{
string clientEndpoint = client.Client.RemoteEndPoint?.ToString() ?? McpServerConfig.UNKNOWN_CLIENT_ENDPOINT;
OnClientConnected?.Invoke(clientEndpoint);
Task clientTask = Task.Run(() => HandleClientAsync(client, cancellationToken));
TrackClientTask(clientTask);
}
}
catch (ObjectDisposedException)
{
// Expected when StopServer() disposes TcpListener while accept is pending.
// If _isRunning is still true here, this is an unexpected disposal — finally block handles state cleanup.
if (_isRunning)
{
VibeLogger.LogWarning(
"server_loop_disposed_while_running",
"TcpListener disposed while server was still marked as running. Exiting loop."
);
}
break;
}
catch (ThreadAbortException ex)
{
// Log and re-throw ThreadAbortException
if (!McpEditorSettings.GetIsDomainReloadInProgress())
{
OnError?.Invoke($"Unexpected thread abort: {ex.Message}");
}
throw;
}
catch (Exception ex)
{
if (!cancellationToken.IsCancellationRequested)
{
string errorMessage = $"Server loop error: {ex.Message}";
OnError?.Invoke(errorMessage);
}
}
}
}
finally
{
bool wasUnexpectedExit = ShouldTreatLoopExitAsUnexpected(_isRunning, cancellationToken);
if (wasUnexpectedExit)
{
VibeLogger.LogWarning(
"server_loop_unexpected_exit",
"ServerLoopAsync exited while _isRunning was still true. Cleaning up and triggering recovery.",
new { cancellationRequested = cancellationToken.IsCancellationRequested }
);
CleanupAfterUnexpectedLoopExit();
OnServerLoopExited?.Invoke();
}
}
}
private static bool ShouldTreatLoopExitAsUnexpected(
bool isRunning,
CancellationToken cancellationToken)
{
// A canceled loop belongs to an intentional shutdown and must not recover a newer server.
return isRunning && !cancellationToken.IsCancellationRequested;
}
/// <summary>
/// Asynchronously accepts a client from the TcpListener.
/// </summary>
private async Task<TcpClient> AcceptTcpClientAsync(TcpListener listener, CancellationToken cancellationToken)
{
try
{
return await AcceptTcpClientAsyncCore(listener, cancellationToken);
}
catch (ThreadAbortException ex)
{
// Log and re-throw ThreadAbortException
if (!McpEditorSettings.GetIsDomainReloadInProgress())
{
OnError?.Invoke($"Unexpected thread abort: {ex.Message}");
}
throw;
}
catch (OperationCanceledException)
{
return null;
}
}
internal static Task<TcpClient> AcceptTcpClientAsyncForTests(
TcpListener listener,
CancellationToken cancellationToken)
{
return AcceptTcpClientAsyncCore(listener, cancellationToken);
}
internal int GetActiveClientTaskCountForTests()
{
return _clientTasks.Count;
}
internal static bool ShouldTreatLoopExitAsUnexpectedForTests(
bool isRunning,
CancellationToken cancellationToken)
{
return ShouldTreatLoopExitAsUnexpected(isRunning, cancellationToken);
}
internal void TrackClientTaskForTests(Task clientTask)
{
TrackClientTask(clientTask);
}
private static async Task<TcpClient> AcceptTcpClientAsyncCore(
TcpListener listener,
CancellationToken cancellationToken)
{
System.Diagnostics.Debug.Assert(listener != null, "TcpListener must be available while accepting clients.");
if (listener == null || cancellationToken.IsCancellationRequested)
{
return null;
}
using CancellationTokenRegistration cancellationRegistration =
cancellationToken.Register(() => listener.Stop());
try
{
return await listener.AcceptTcpClientAsync();
}
catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested)
{
return null;
}
catch (SocketException) when (cancellationToken.IsCancellationRequested)
{
return null;
}
catch (InvalidOperationException) when (cancellationToken.IsCancellationRequested)
{
return null;
}
}
private void TrackClientTask(Task clientTask)
{
System.Diagnostics.Debug.Assert(clientTask != null, "Client handler task must be created before tracking.");
if (clientTask == null)
{
return;
}
bool added = _clientTasks.TryAdd(clientTask.Id, clientTask);
System.Diagnostics.Debug.Assert(added, "Client handler task id should be unique while tracking.");
clientTask.ContinueWith(completedTask =>
{
_clientTasks.TryRemove(completedTask.Id, out _);
if (completedTask.IsFaulted)
{
Exception exception = completedTask.Exception?.GetBaseException();
OnError?.Invoke($"Client handler task faulted: {exception?.Message}");
}
}, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
}
/// <summary>
/// Handles communication with the client using Content-Length framing.
/// </summary>
private async Task HandleClientAsync(TcpClient client, CancellationToken cancellationToken)
{
string clientEndpoint = client.Client.RemoteEndPoint?.ToString() ?? McpServerConfig.UNKNOWN_CLIENT_ENDPOINT;
// Initialize new components for Content-Length framing
DynamicBufferManager bufferManager = null;
MessageReassembler messageReassembler = null;
try
{
using (client)
using (NetworkStream stream = client.GetStream())
{
// Check for existing connection from same endpoint and close it
string clientKey = GenerateClientKey(clientEndpoint);
if (_connectedClients.TryGetValue(clientKey, out ConnectedClient existingClient))
{
existingClient.Stream?.Close();
_connectedClients.TryRemove(clientKey, out _);
// Notify tool disconnected
OnToolDisconnected?.Invoke(existingClient.ClientName);
}
// Add new client to connected clients for notification broadcasting
int clientPort = (client.Client.RemoteEndPoint as System.Net.IPEndPoint)?.Port ?? 0;
ConnectedClient connectedClient = new ConnectedClient(clientEndpoint, stream, clientPort);
_connectedClients.TryAdd(clientKey, connectedClient);
// Initialize new framing components
bufferManager = new DynamicBufferManager();
messageReassembler = new MessageReassembler(bufferManager);
// Start with initial buffer size
byte[] buffer = bufferManager.GetBuffer(BufferConfig.INITIAL_BUFFER_SIZE);
while (!cancellationToken.IsCancellationRequested && client.Connected)
{
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;
// JSON-RPC processing and response sending with client context
string responseJson = await JsonRpcProcessor.ProcessRequest(requestJson, clientEndpoint);
// Only send response if it's not null (notifications return null)
if (!string.IsNullOrEmpty(responseJson))
{
// Check stream and client state before attempting write
if (!stream.CanWrite || !client.Connected || cancellationToken.IsCancellationRequested)
{
return; // Skip the write operation
}
// Send response with Content-Length framing
string framedResponse = CreateContentLengthFrame(responseJson);
byte[] responseData = Encoding.UTF8.GetBytes(framedResponse);
await stream.WriteAsync(responseData, 0, responseData.Length, cancellationToken);
}
}
// Validate reassembler state and clear if needed
if (!messageReassembler.ValidateState())
{
}
}
}
}
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)
{
OnError?.Invoke(ex.Message);
}
finally
{
// Dispose of framing components
try
{
messageReassembler?.Dispose();
bufferManager?.Dispose();
}
catch (Exception ex)
{
OnError?.Invoke($"Error during client disposal: {ex.Message}");
}
// Remove client from connected clients list
// Find client by endpoint to get the correct key
ConnectedClient clientToRemove = _connectedClients.Values
.FirstOrDefault(c => c.Endpoint == clientEndpoint);
if (clientToRemove != null)
{
string clientKey = GenerateClientKey(clientToRemove.Endpoint);
_connectedClients.TryRemove(clientKey, out _);
// Notify tool disconnected
OnToolDisconnected?.Invoke(clientToRemove.ClientName);
}
client.Close();
OnClientDisconnected?.Invoke(clientEndpoint);
}
}
/// <summary>
/// Sends a pre-formatted JSON-RPC notification to all connected clients using Content-Length framing.
/// </summary>
/// <param name="notificationJson">The complete JSON-RPC notification string</param>
public void SendNotificationToClients(string notificationJson)
{
if (_connectedClients.IsEmpty)
{
return;
}
// Frame the notification with Content-Length header
string framedNotification = CreateContentLengthFrame(notificationJson);
byte[] notificationData = Encoding.UTF8.GetBytes(framedNotification);
SendNotificationDataAsync(notificationData).Forget();
}
/// <summary>
/// Sends a shutdown notification to all connected clients.
/// This should be called before disconnecting clients so TypeScript side can
/// differentiate between domain reload (temporary) and editor quit (permanent).
/// </summary>
/// <param name="reason">The reason for server shutdown</param>
public void SendShutdownNotification(ServerShutdownReason reason)
{
if (_connectedClients.IsEmpty)
{
return;
}
// Create JSON-RPC 2.0 notification with shutdown reason
string notificationJson = $"{{\"jsonrpc\":\"2.0\",\"method\":\"notifications/server/shutdown\",\"params\":{{\"reason\":\"{reason}\"}}}}";
// Frame the notification with Content-Length header
string framedNotification = CreateContentLengthFrame(notificationJson);
byte[] notificationData = Encoding.UTF8.GetBytes(framedNotification);
// Send synchronously to ensure it's sent before connection closes
SendNotificationDataSync(notificationData);
}
/// <summary>
/// Send notification data to all connected clients synchronously.
/// Used for shutdown notifications to ensure delivery before connection closes.
/// </summary>
private void SendNotificationDataSync(byte[] notificationData)
{
foreach (KeyValuePair<string, ConnectedClient> client in _connectedClients)
{
try
{
if (client.Value.Stream?.CanWrite == true)
{
client.Value.Stream.Write(notificationData, 0, notificationData.Length);
client.Value.Stream.Flush();
}
}
catch (Exception)
{
// Ignore errors during shutdown notification - client may already be disconnected
}
}
}
/// <summary>
/// Send notification data to all connected clients
/// </summary>
private async Task SendNotificationDataAsync(byte[] notificationData)
{
List<string> clientsToRemove = new List<string>();
foreach (KeyValuePair<string, ConnectedClient> client in _connectedClients)
{
try
{
if (client.Value.Stream?.CanWrite == true)
{
await client.Value.Stream.WriteAsync(notificationData, 0, notificationData.Length);
}
else
{
clientsToRemove.Add(client.Key);
}
}
catch (Exception ex)
{
OnError?.Invoke($"Error writing notification to client {client.Key}: {ex.Message}");
clientsToRemove.Add(client.Key);
}
}
// Remove disconnected clients
foreach (string clientKey in clientsToRemove)
{
if (_connectedClients.TryRemove(clientKey, out ConnectedClient removedClient))
{
// Notify tool disconnected
OnToolDisconnected?.Invoke(removedClient.ClientName);
}
}
}
/// <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}";
}
/// <summary>
/// Determines if the given exception represents a normal client disconnection.
/// </summary>
/// <param name="ex">The exception to evaluate</param>
/// <returns>True if the exception represents a normal disconnection, false otherwise</returns>
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;
}
}
/// <summary>
/// Recursively checks inner exceptions for normal disconnection scenarios
/// </summary>
/// <param name="ex">The exception to check</param>
/// <returns>True if any inner exception indicates a normal disconnection</returns>
private static bool IsNormalDisconnectionByInnerException(Exception ex)
{
Exception innerEx = ex.InnerException;
while (innerEx != null)
{
if (IsNormalDisconnectionException(innerEx))
{
return true;
}
innerEx = innerEx.InnerException;
}
return false;
}
/// <summary>
/// Releases resources.
/// </summary>
public void Dispose()
{
StopServer();
}
private enum ServerStopMode
{
Normal,
BeforeDomainReload,
UnexpectedLoopExit
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ef5a31eaf3f994a32aaa20b307bcf510
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,89 @@
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// A class for managing settings related to the Unity MCP Server.
/// </summary>
public static class McpServerConfig
{
/// <summary>
/// Default port number.
/// </summary>
public const int DEFAULT_PORT = 8700;
/// <summary>
/// Buffer size for TCP/IP communication.
/// </summary>
public const int BUFFER_SIZE = 4096;
/// <summary>
/// Wait timeout in seconds when stopping the server.
/// </summary>
public const int SHUTDOWN_TIMEOUT_SECONDS = 5;
/// <summary>
/// Version string for JSON-RPC 2.0.
/// </summary>
public const string JSONRPC_VERSION = "2.0";
/// <summary>
/// Internal error code.
/// </summary>
public const int INTERNAL_ERROR_CODE = -32603;
/// <summary>
/// Minimum port number.
/// </summary>
public const int MIN_PORT_NUMBER = 1024;
/// <summary>
/// Maximum port number.
/// </summary>
public const int MAX_PORT_NUMBER = 65535;
/// <summary>
/// Unknown client endpoint.
/// </summary>
public const string UNKNOWN_CLIENT_ENDPOINT = "Unknown";
/// <summary>
/// Default log type.
/// </summary>
public const string DEFAULT_LOG_TYPE = "All";
/// <summary>
/// Default maximum number of logs.
/// </summary>
public const int DEFAULT_MAX_LOG_COUNT = 100;
/// <summary>
/// Default search text.
/// </summary>
public const string DEFAULT_SEARCH_TEXT = "";
/// <summary>
/// Whether to include stack traces by default.
/// </summary>
public const bool DEFAULT_INCLUDE_STACK_TRACE = true;
/// <summary>
/// Default line number.
/// </summary>
public const int DEFAULT_LINE_NUMBER = 0;
/// <summary>
/// Date and time format string.
/// </summary>
public const string TIMESTAMP_FORMAT = "yyyy-MM-dd HH:mm:ss";
/// <summary>
/// ISO format date and time string.
/// </summary>
public const string ISO_DATETIME_FORMAT = "yyyy-MM-ddTHH:mm:ss.fffZ";
/// <summary>
/// Default maximum depth for JSON serialization.
/// Set to int.MaxValue to effectively disable depth limiting while preventing infinite loops.
/// </summary>
public const int DEFAULT_JSON_MAX_DEPTH = int.MaxValue;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1693b2ef898dc48bdb0448a656e9452b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,958 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Net.Sockets;
using UnityEditor;
using Newtonsoft.Json;
using UnityEngine;
namespace io.github.hatayama.uLoopMCP
{
// Related classes:
// - McpBridgeServer: The TCP server instance that this class manages.
// - McpEditorWindow: The UI for starting and stopping the server.
// - AssemblyReloadEvents: Used to handle server state across domain reloads.
/// <summary>
/// Manages the state of the MCP Server with SessionState and automatically restores it on assembly reload.
/// </summary>
[InitializeOnLoad]
public static class McpServerController
{
private static McpBridgeServer mcpServer;
private static readonly SemaphoreSlim StartupSemaphore = new SemaphoreSlim(1, 1);
private static long startupProtectionUntilTicks = 0; // UTC ticks
private static Task _currentRecoveryTask;
private static bool IsBackgroundUnityProcess()
{
bool isBatchMode = Application.isBatchMode;
bool isAssetImportWorker = AssetDatabase.IsAssetImportWorkerProcess();
return isBatchMode || isAssetImportWorker;
}
/// <summary>
/// The current MCP server instance.
/// </summary>
public static McpBridgeServer CurrentServer => mcpServer;
/// <summary>
/// Whether the server is running.
/// </summary>
public static bool IsServerRunning => mcpServer?.IsRunning ?? false;
/// <summary>
/// The server's port number.
/// </summary>
public static int ServerPort => mcpServer?.Port ?? McpEditorSettings.GetCustomPort();
/// <summary>
/// Current recovery task. Can be awaited by other components to ensure recovery completes first.
/// </summary>
public static Task RecoveryTask => _currentRecoveryTask;
static McpServerController()
{
InitializeOnLoad();
}
private static void InitializeOnLoad()
{
if (IsBackgroundUnityProcess())
{
VibeLogger.LogInfo("server_controller_background_skip", "Skipping MCP server controller initialization in background Unity process.");
return;
}
// Register cleanup for when Unity exits.
EditorApplication.quitting += OnEditorQuitting;
// Processing before assembly reload.
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
// Processing after assembly reload.
AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload;
// Domain Reload disabled (Enter Play Mode Settings) causes static constructor re-entry
McpBridgeServer.OnServerLoopExited -= OnServerLoopUnexpectedlyExited;
McpBridgeServer.OnServerLoopExited += OnServerLoopUnexpectedlyExited;
// Initialize connected tools monitoring service
// Note: ConnectedToolsMonitoringService has [InitializeOnLoad] so it's automatically initialized
// This comment ensures the service initialization order is documented
// Recovery binds a TCP listener and may touch config files, so keep it off the
// synchronous InitializeOnLoad path while preserving automatic startup.
ScheduleStartupRecovery(
action => EditorApplication.delayCall += () => action(),
RestoreServerStateIfNeeded);
}
internal static Task ScheduleStartupRecovery(
Action<Action> scheduleDelayCall,
Func<Task> restoreServerState)
{
Debug.Assert(scheduleDelayCall != null, "scheduleDelayCall must not be null");
Debug.Assert(restoreServerState != null, "restoreServerState must not be null");
TaskCompletionSource<bool> scheduledRecoveryCompletionSource = new TaskCompletionSource<bool>();
_currentRecoveryTask = scheduledRecoveryCompletionSource.Task;
scheduleDelayCall(() =>
{
Task restoreTask;
try
{
restoreTask = restoreServerState();
}
catch (Exception ex)
{
CompleteScheduledStartupRecovery(Task.FromException(ex), scheduledRecoveryCompletionSource);
return;
}
if (restoreTask.IsCompleted)
{
CompleteScheduledStartupRecovery(restoreTask, scheduledRecoveryCompletionSource);
return;
}
_ = restoreTask.ContinueWith(task =>
{
CompleteScheduledStartupRecovery(task, scheduledRecoveryCompletionSource);
}, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.FromCurrentSynchronizationContext());
});
return scheduledRecoveryCompletionSource.Task;
}
private static void CompleteScheduledStartupRecovery(
Task restoreTask,
TaskCompletionSource<bool> scheduledRecoveryCompletionSource)
{
if (ReferenceEquals(_currentRecoveryTask, scheduledRecoveryCompletionSource.Task))
{
_currentRecoveryTask = null;
}
if (restoreTask.IsCanceled)
{
scheduledRecoveryCompletionSource.SetCanceled();
return;
}
if (restoreTask.IsFaulted)
{
VibeLogger.LogError("server_startup_restore_failed",
$"Failed to restore server: {restoreTask.Exception?.GetBaseException().Message}");
scheduledRecoveryCompletionSource.SetException(restoreTask.Exception.GetBaseException());
return;
}
scheduledRecoveryCompletionSource.SetResult(true);
}
/// <summary>
/// Starts the server.
/// </summary>
/// <param name="port">
/// The port number to bind to. Use -1 to fall back to the saved custom port
/// from <see cref="McpEditorSettings.GetCustomPort"/>. Defaults to -1.
/// </param>
public static async void StartServer(int port = -1)
{
await StartServerWithUseCaseAsync(port);
}
/// <summary>
/// Starts the server using new UseCase implementation.
/// </summary>
private static async Task StartServerWithUseCaseAsync(int port)
{
if (IsBackgroundUnityProcess())
{
VibeLogger.LogInfo("server_start_ignored", "background_process");
return;
}
// Signal server is starting for CLI detection
string serverStartingLockToken = CreateOptionalServerStartingLock();
bool startupLockReleasedByPrewarm = false;
try
{
// Always stop the existing server first (to release the port)
if (mcpServer != null)
{
await StopServerWithUseCaseAsync();
}
DynamicCodeStartupTelemetry.Reset();
DynamicCodeForegroundWarmupState.Reset();
// Execute initialization UseCase
McpServerInitializationUseCase useCase = new();
ServerInitializationSchema schema = new()
{
Port = port,
PreserveStartupLockUntilExplicitRelease = true
};
System.Threading.CancellationToken cancellationToken = System.Threading.CancellationToken.None;
var result = await useCase.ExecuteAsync(schema, cancellationToken);
if (!result.Success)
{
// Error message already handled by UseCase
UnityEngine.Debug.LogError($"Server startup failed: {result.Message}");
return;
}
// UseCase creates a new server instance, so we keep a reference here
// for compatibility with existing code
mcpServer = result.ServerInstance;
DynamicCodeStartupTelemetry.MarkServerReady();
CustomToolManager.WarmupRegistry();
DynamicCodeServices.ResetServerScopedServices();
IPrewarmDynamicCodeUseCase prewarmDynamicCodeUseCase =
await DynamicCodeServices.GetPrewarmDynamicCodeUseCaseAsync(serverStartingLockToken);
prewarmDynamicCodeUseCase.Request();
startupLockReleasedByPrewarm = true;
}
finally
{
if (!startupLockReleasedByPrewarm)
{
ServerStartingLockService.DeleteOwnedLockFile(serverStartingLockToken);
}
}
}
/// <summary>
/// Stops the server.
/// </summary>
public static async void StopServer()
{
await StopServerWithUseCaseAsync();
}
/// <summary>
/// Stops the server using new UseCase implementation.
/// </summary>
private static async Task StopServerWithUseCaseAsync()
{
if (IsBackgroundUnityProcess())
{
VibeLogger.LogInfo("server_stop_ignored", "background_process");
return;
}
ClearStartupProtection();
// Execute shutdown UseCase
McpServerShutdownUseCase useCase = new(new McpServerStartupService());
ServerShutdownSchema schema = new() { ForceShutdown = false };
System.Threading.CancellationToken cancellationToken = System.Threading.CancellationToken.None;
var result = await useCase.ExecuteAsync(schema, cancellationToken);
if (result.Success)
{
// Server stopped by UseCase, so clear the reference
mcpServer = null;
// Clear session state to reflect server stopped
McpEditorSettings.ClearServerSession();
DynamicCodeStartupTelemetry.Reset();
DynamicCodeForegroundWarmupState.Reset();
DynamicCodeServices.ResetServerScopedServices();
}
else
{
// Error message already handled by UseCase
UnityEngine.Debug.LogError($"Server shutdown failed: {result.Message}");
}
}
/// <summary>
/// Processing before assembly reload.
/// </summary>
private static void OnBeforeAssemblyReload()
{
ClearStartupProtection();
// Create and execute DomainReloadRecoveryUseCase instance
DomainReloadRecoveryUseCase useCase = new();
ServiceResult<string> result = useCase.ExecuteBeforeDomainReload(mcpServer);
// Clear instance if server shutdown succeeded
if (result.Success)
{
mcpServer = null;
}
DynamicCodeStartupTelemetry.Reset();
DynamicCodeForegroundWarmupState.Reset();
DynamicCodeServices.ResetServerScopedServicesBeforeDomainReload();
}
/// <summary>
/// Processing after assembly reload.
/// </summary>
private static void OnAfterAssemblyReload()
{
// Create and execute DomainReloadRecoveryUseCase instance
DomainReloadRecoveryUseCase useCase = new();
_ = useCase.ExecuteAfterDomainReloadAsync(System.Threading.CancellationToken.None).ContinueWith(task =>
{
if (task.IsFaulted)
{
UnityEngine.Debug.LogError($"Domain reload recovery failed: {task.Exception}");
}
}, TaskScheduler.FromCurrentSynchronizationContext());
// Synchronize MCP editor configurations with current debug symbol state after domain reload
// Use delayCall to ensure the editor is fully initialized before file I/O
EditorApplication.delayCall += () =>
{
try
{
McpDebugStateUpdater.UpdateAllConfigurationsForDebugState();
}
catch (System.Exception ex)
{
UnityEngine.Debug.LogWarning($"MCP debug-state configuration sync failed: {ex.Message}");
}
};
}
/// <summary>
/// Restores the server state if necessary.
/// </summary>
private static Task RestoreServerStateIfNeeded()
{
if (IsBackgroundUnityProcess())
{
VibeLogger.LogInfo("server_restore_skipped", "background_process");
return Task.CompletedTask;
}
int savedPort = McpEditorSettings.GetCustomPort();
bool isAfterCompile = McpEditorSettings.GetIsAfterCompile();
if (mcpServer?.IsRunning == true)
{
if (isAfterCompile)
{
McpEditorSettings.ClearAfterCompileFlag();
}
return Task.CompletedTask;
}
if (isAfterCompile)
{
McpEditorSettings.ClearAfterCompileFlag();
}
int portToUse = savedPort;
// Centralized, coalesced startup request
// Store the task so McpEditorWindow can await it to prevent race conditions
return StartRecoveryIfNeededAsync(portToUse, isAfterCompile, CancellationToken.None);
}
/// <summary>
/// Executes server recovery with retries on the original port.
/// Does not change the port number; only attempts recovery on the specified port.
/// </summary>
private static void TryRestoreServerWithRetry(int port, int retryCount)
{
const int maxRetries = 3;
try
{
// If there is an existing server instance, ensure it is stopped.
if (mcpServer != null)
{
mcpServer.Dispose();
mcpServer = null;
}
// Try to start server on the requested port only
mcpServer = new McpBridgeServer();
mcpServer.StartServer(port);
// Update settings with the actual port used (same as requested)
if (McpEditorSettings.GetCustomPort() != port)
{
McpEditorSettings.SetCustomPort(port);
}
// Clear server-side reconnecting flag on successful restoration
// NOTE: Do NOT clear UI display flag here - let it be cleared by timeout or client connection
McpEditorSettings.SetIsReconnecting(false);
// Tools changed notification will be sent by OnAfterAssemblyReload
}
catch (System.Exception)
{
// If the maximum number of retries has not been reached, try again.
if (retryCount < maxRetries)
{
// Wait for port release before retry
RetryServerRestoreAsync(port, retryCount).Forget();
}
else
{
// If it ultimately fails, clear the SessionState.
McpEditorSettings.ClearServerSession();
}
}
}
/// <summary>
/// Prevent CLI from misdetecting a busy state when server startup is intentionally skipped.
/// </summary>
private static void DeleteAllLockFiles()
{
CompilationLockService.DeleteLockFile();
DomainReloadDetectionService.DeleteLockFile();
}
/// <summary>
/// Cleanup on Unity exit.
/// Disposes the TCP listener and marks the server as stopped so the CLI
/// does not attempt to connect to a stale port after the editor closes.
/// </summary>
private static void OnEditorQuitting()
{
if (mcpServer != null)
{
try
{
mcpServer.Dispose();
}
finally
{
mcpServer = null;
}
}
DynamicCodeForegroundWarmupState.Reset();
DynamicCodeServices.ResetServerScopedServices();
McpEditorSettings.ClearServerSession();
}
/// <summary>
/// OnServerLoopExited fires from the thread pool, but Unity APIs (EditorSettings,
/// VibeLogger with SerializedObject, etc.) are main-thread-only.
/// EditorApplication.delayCall marshals the recovery to the next editor tick.
/// </summary>
private static void OnServerLoopUnexpectedlyExited()
{
// OnServerLoopExited fires from thread pool — marshal to main thread for Unity API safety
EditorApplication.delayCall += () =>
{
ClearStartupProtection();
VibeLogger.LogWarning(
"server_loop_exit_detected",
"Detected unexpected server loop exit. Initiating automatic recovery.",
new { port = mcpServer?.Port }
);
int portToRecover = mcpServer?.Port ?? McpEditorSettings.GetCustomPort();
// Resources already cleaned up by CleanupAfterUnexpectedLoopExit — just clear the reference
mcpServer = null;
// The server just crashed — startup protection blocks recovery if the crash happens
// within the 5-second protection window after a successful start
System.Threading.Volatile.Write(ref startupProtectionUntilTicks, 0L);
_currentRecoveryTask = StartRecoveryIfNeededAsync(portToRecover, false, CancellationToken.None);
_ = _currentRecoveryTask.ContinueWith(task =>
{
if (ReferenceEquals(_currentRecoveryTask, task))
{
_currentRecoveryTask = null;
}
if (task.IsFaulted)
{
VibeLogger.LogError(
"server_auto_recovery_failed",
$"Automatic recovery after unexpected exit failed: {task.Exception?.GetBaseException().Message}"
);
McpEditorSettings.ClearServerSession();
}
}, TaskScheduler.FromCurrentSynchronizationContext());
};
}
/// <summary>
/// Processes pending compile requests.
/// </summary>
private static void ProcessPendingCompileRequests()
{
// Temporarily disabled to avoid main thread errors due to SessionState operations.
// TODO: Re-enable after resolving the main thread issue.
// CompileSessionState.StartForcedRecompile();
}
/// <summary>
/// Gets server status information.
/// </summary>
public static (bool isRunning, int port, bool wasRestoredFromSession) GetServerStatus()
{
bool wasRestored = McpEditorSettings.GetIsServerRunning();
return (IsServerRunning, ServerPort, wasRestored);
}
/// <summary>
/// Send tools changed notification to TypeScript side
/// </summary>
private static void SendToolsChangedNotification()
{
// Log with stack trace to identify caller
System.Diagnostics.StackTrace stackTrace = new System.Diagnostics.StackTrace(true);
string callerInfo = stackTrace.GetFrame(1)?.GetMethod()?.Name ?? "Unknown";
if (mcpServer == null)
{
return;
}
// Send MCP standard notification only
var notificationParams = new
{
timestamp = System.DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"),
message = "Unity tools have been updated"
};
var mcpNotification = new
{
jsonrpc = McpServerConfig.JSONRPC_VERSION,
method = "notifications/tools/list_changed",
@params = notificationParams
};
string mcpNotificationJson = JsonConvert.SerializeObject(mcpNotification);
mcpServer.SendNotificationToClients(mcpNotificationJson);
}
/// <summary>
/// Manually trigger tool change notification
/// Public method for external calls (e.g., from UnityToolRegistry)
/// </summary>
public static void TriggerToolChangeNotification()
{
if (IsServerRunning)
{
SendToolsChangedNotification();
}
}
/// <summary>
/// Send tool notification after compilation with frame delay
/// </summary>
private static async Task SendToolNotificationAfterCompilationAsync()
{
// Use frame delay for timing adjustment after domain reload
// This ensures Unity Editor is in a stable state before sending notifications
await EditorDelay.DelayFrame(1);
CustomToolManager.NotifyToolChanges();
}
/// <summary>
/// Restore server after compilation with frame delay.
/// Currently kept as a helper; recovery logic is unified in StartRecoveryIfNeededAsync.
/// </summary>
private static async Task RestoreServerAfterCompileAsync(int port)
{
// Wait a short while for timing adjustment (TCP port release)
await EditorDelay.DelayFrame(1);
TryRestoreServerWithRetry(port, 0);
}
/// <summary>
/// Restore server on startup with frame delay
/// </summary>
private static async Task RestoreServerOnStartupAsync(int port)
{
// Wait for Unity Editor to be ready before auto-starting
await EditorDelay.DelayFrame(1);
_ = StartRecoveryIfNeededAsync(port, false, CancellationToken.None).ContinueWith(task =>
{
if (task.IsFaulted)
{
VibeLogger.LogError("server_startup_restore_failed",
$"Failed to restore server: {task.Exception?.GetBaseException().Message}");
}
}, TaskScheduler.FromCurrentSynchronizationContext());
}
/// <summary>
/// Retry server restore with frame delay on the same port.
/// </summary>
private static async Task RetryServerRestoreAsync(int port, int retryCount)
{
// Wait longer for port release before retry
await EditorDelay.DelayFrame(5);
TryRestoreServerWithRetry(port, retryCount + 1);
}
/// <summary>
/// Start UI display timeout timer for reconnecting message
/// </summary>
private static async Task StartReconnectionUITimeoutAsync()
{
// Wait for the timeout period (convert seconds to frames at ~60fps)
int timeoutFrames = McpConstants.RECONNECTION_TIMEOUT_SECONDS * 60;
await EditorDelay.DelayFrame(timeoutFrames);
// Check if UI flag is still set after timeout
bool isStillShowingUI = McpEditorSettings.GetShowReconnectingUI();
if (isStillShowingUI)
{
McpEditorSettings.ClearReconnectingFlags();
}
}
/// <summary>
/// Clear reconnecting flags when client connects
/// Called by UI or bridge server when client connection is detected
/// </summary>
public static void ClearReconnectingFlag()
{
bool wasReconnecting = McpEditorSettings.GetIsReconnecting();
bool wasShowingUI = McpEditorSettings.GetShowReconnectingUI();
if (wasReconnecting || wasShowingUI)
{
McpEditorSettings.ClearReconnectingFlags();
}
}
/// <summary>
/// Finds an available port starting from the given port number
/// Delegates to NetworkUtility for consistent port finding behavior.
/// </summary>
/// <param name="startPort">The starting port number to check</param>
/// <returns>The first available port number</returns>
private static int FindAvailablePort(int startPort)
{
return NetworkUtility.FindAvailablePort(startPort);
}
private static bool TryFindFallbackPort(int currentPort, out int fallbackPort)
{
const int maxAttempts = 10;
for (int offset = 1; offset <= maxAttempts; offset++)
{
int candidatePort = currentPort + offset;
if (!McpPortValidator.ValidatePort(candidatePort, "for recovery fallback"))
{
continue;
}
if (NetworkUtility.IsPortInUse(candidatePort))
{
continue;
}
fallbackPort = candidatePort;
return true;
}
fallbackPort = currentPort;
return false;
}
private static void LogRecoveryFallback(int sourcePort, int fallbackPort, string reason)
{
string message = $"Recovery fallback activated: {sourcePort} -> {fallbackPort} ({reason})";
VibeLogger.LogWarning("recovery_port_fallback", message);
Debug.LogWarning($"[{McpConstants.PROJECT_NAME}] {message}");
}
/// <summary>
/// Validates server configuration before starting
/// Implements fail-fast behavior for invalid configurations
/// </summary>
private static void ValidateServerConfiguration(int port)
{
// Validate port number using shared validator
if (!McpPortValidator.ValidatePort(port, "for MCP server"))
{
throw new System.ArgumentOutOfRangeException(nameof(port),
$"Port number must be between 1 and 65535. Received: {port}");
}
// Validate Unity Editor state
if (EditorApplication.isCompiling)
{
throw new System.InvalidOperationException(
"Cannot start MCP server while Unity is compiling. Please wait for compilation to complete.");
}
// Server configuration validation passed
// Note: Port availability and system port conflicts are handled by FindAvailablePort
}
public static bool IsStartupProtectionActive()
{
long nowTicks = DateTime.UtcNow.Ticks;
return nowTicks < System.Threading.Volatile.Read(ref startupProtectionUntilTicks);
}
private static void ActivateStartupProtection(int milliseconds)
{
long untilTicks = DateTime.UtcNow.AddMilliseconds(milliseconds).Ticks;
System.Threading.Volatile.Write(ref startupProtectionUntilTicks, untilTicks);
VibeLogger.LogInfo("startup_protection_active", $"window={milliseconds}ms");
}
/// <summary>
/// Clears startup protection so recovery paths can restart the server immediately.
/// </summary>
private static void ClearStartupProtection()
{
System.Threading.Volatile.Write(ref startupProtectionUntilTicks, 0L);
}
/// <summary>
/// Centralized, coalesced recovery start.
/// Attempts recovery on the specified port for up to 5 seconds without changing the port number.
/// </summary>
public static async Task StartRecoveryIfNeededAsync(int savedPort, bool isAfterCompile, CancellationToken cancellationToken)
{
if (IsBackgroundUnityProcess())
{
VibeLogger.LogInfo("server_start_ignored", "background_process");
return;
}
// Ensure stale reload locks are cleaned up before recovery.
// Why not clear serverstarting.lock here: a previous generation may still be finishing
// and ownership is now tracked per startup token below.
DomainReloadDetectionService.DeleteLockFile();
CompilationLockService.DeleteLockFile();
VibeLogger.LogInfo("startup_request", $"port={savedPort}");
if (IsStartupProtectionActive())
{
VibeLogger.LogInfo("server_start_ignored", "startup_protection_active");
return;
}
await StartupSemaphore.WaitAsync(cancellationToken);
string serverStartingLockToken = null;
try
{
// If any server is already running, ignore this request to prevent double-binding
if (mcpServer != null && mcpServer.IsRunning)
{
VibeLogger.LogInfo("server_start_ignored", $"already_running port={mcpServer.Port}");
return;
}
serverStartingLockToken = CreateOptionalServerStartingLock();
// Ensure previous instance is fully disposed before trying to bind a new one
if (mcpServer != null)
{
try
{
mcpServer.Dispose();
VibeLogger.LogInfo("server_disposed_before_bind", "disposed previous server instance");
}
catch (Exception ex)
{
VibeLogger.LogWarning("server_dispose_failed", ex.Message);
}
finally
{
mcpServer = null;
}
}
int chosenPort = savedPort;
bool savedPortInUse = NetworkUtility.IsPortInUse(savedPort);
if (savedPortInUse && TryFindFallbackPort(savedPort, out int preBindFallbackPort))
{
chosenPort = preBindFallbackPort;
LogRecoveryFallback(savedPort, chosenPort, "saved_port_in_use_before_bind");
}
bool started = await TryBindWithWaitAsync(
chosenPort,
5000,
250,
cancellationToken,
clearServerStartingLockWhenReady: false);
if (!started)
{
if (TryFindFallbackPort(chosenPort, out int fallbackPort) && fallbackPort != chosenPort)
{
int previousAttemptPort = chosenPort;
chosenPort = fallbackPort;
LogRecoveryFallback(previousAttemptPort, chosenPort, "bind_retry_timeout");
started = await TryBindWithWaitAsync(
chosenPort,
5000,
250,
cancellationToken,
clearServerStartingLockWhenReady: false);
}
}
if (!started)
{
// Ensure session reflects stopped state on failure
McpEditorSettings.ClearServerSession();
McpEditorSettings.ClearReconnectingFlags();
Debug.LogError($"[{McpConstants.PROJECT_NAME}] Recovery failed: no available port to bind. SavedPort={savedPort}, LastAttemptPort={chosenPort}");
throw new InvalidOperationException($"Failed to bind any recovery port. SavedPort={savedPort}, LastAttemptPort={chosenPort}.");
}
// Auto-update configuration files after startup.
// This keeps external editor settings aligned with path updates and recovery port fallback.
try
{
McpConfigAutoUpdater.UpdateAllConfiguredEditors(chosenPort);
}
catch (Exception ex)
{
VibeLogger.LogWarning("config_auto_update_failed", $"Failed to auto-update configurations: {ex.Message}");
Debug.LogWarning($"[{McpConstants.PROJECT_NAME}] Failed to auto-update configurations: {ex.Message}");
// Continue with running server even if config update fails
}
// Mark running and update settings
SaveRunningServerSession(chosenPort);
// Clear reconnection-related flags on successful recovery
McpEditorSettings.ClearReconnectingFlags();
McpEditorSettings.ClearPostCompileReconnectingUI();
DynamicCodeStartupTelemetry.MarkServerReady();
CustomToolManager.WarmupRegistry();
DynamicCodeServices.ResetServerScopedServices();
IPrewarmDynamicCodeUseCase prewarmDynamicCodeUseCase =
await DynamicCodeServices.GetPrewarmDynamicCodeUseCaseAsync(serverStartingLockToken);
prewarmDynamicCodeUseCase.Request();
ActivateStartupProtection(5000);
}
catch
{
ServerStartingLockService.DeleteOwnedLockFile(serverStartingLockToken);
throw;
}
finally
{
StartupSemaphore.Release();
}
}
private static async Task<bool> TryBindWithWaitAsync(
int port,
int maxWaitMs,
int stepMs,
CancellationToken cancellationToken,
bool clearServerStartingLockWhenReady = true)
{
int remainingMs = maxWaitMs;
while (true)
{
VibeLogger.LogInfo("binding_attempt", $"port={port}");
McpBridgeServer server = null;
try
{
// Defensive: dispose any non-running stale instance before creating a new one
if (mcpServer != null && !mcpServer.IsRunning)
{
try
{
mcpServer.Dispose();
VibeLogger.LogInfo("server_disposed_before_bind", "disposed stale instance");
}
catch (Exception ex)
{
VibeLogger.LogWarning("server_dispose_failed", ex.Message);
}
finally
{
mcpServer = null;
}
}
server = new McpBridgeServer();
server.StartServer(port, clearServerStartingLockWhenReady);
mcpServer = server;
VibeLogger.LogInfo("binding_success", $"port={port}");
return true;
}
catch (Exception ex)
{
// Ensure partially created server is cleaned up on failure
try { server?.Dispose(); } catch { }
// Unwrap SocketException details if present
SocketException sockEx = ex as SocketException;
if (ex is InvalidOperationException && ex.InnerException is SocketException innerSock)
{
sockEx = innerSock;
}
if (sockEx != null)
{
VibeLogger.LogWarning("binding_failed", $"port={port} code={sockEx.SocketErrorCode} hresult={sockEx.HResult} native={sockEx.ErrorCode}");
}
else
{
VibeLogger.LogWarning("binding_failed", $"port={port} code=Unknown hresult={ex.HResult}");
}
if (remainingMs <= 0)
{
return false;
}
int delay = stepMs <= 0 ? remainingMs : Math.Min(stepMs, remainingMs);
await TimerDelay.Wait(delay, cancellationToken);
remainingMs -= delay;
}
}
}
private static void SaveRunningServerSession(int port)
{
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
string serverSessionId = Guid.NewGuid().ToString("N");
McpEditorSettings.SetRunningServerSession(port, projectRoot, serverSessionId);
}
internal static string CreateOptionalServerStartingLock(Func<string> createLockFile = null)
{
Func<string> createLockFileCore = createLockFile ?? ServerStartingLockService.CreateLockFile;
string serverStartingLockToken = createLockFileCore();
if (!string.IsNullOrEmpty(serverStartingLockToken))
{
return serverStartingLockToken;
}
// Why: serverstarting.lock only improves busy diagnostics for external callers; the
// listener itself can still start and recover safely without it.
// Why not fail fast here: a transient file lock would otherwise turn an optional
// readiness hint into a full startup outage for launch and recovery paths.
VibeLogger.LogWarning(
"server_starting_lock_optional",
"Proceeding without serverstarting.lock because the readiness hint could not be created.");
return null;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bbceee50635c848f18192ed5ce5280a6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,425 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Reassembles fragmented TCP messages using Content-Length framing.
/// Handles partial frames and maintains state across multiple data chunks.
/// </summary>
public class MessageReassembler : IDisposable
{
private readonly DynamicBufferManager _bufferManager;
private byte[] _assemblyBuffer;
private int _currentDataLength = 0;
private bool _disposed = false;
// Frame parsing state
private int _expectedContentLength = -1;
private int _headerLength = -1;
private bool _headerParsed = false;
// Statistics
private int _totalMessagesReassembled = 0;
private int _totalDataChunksProcessed = 0;
/// <summary>
/// Initializes a new instance of the MessageReassembler.
/// </summary>
/// <param name="bufferManager">The buffer manager to use for memory management</param>
public MessageReassembler(DynamicBufferManager bufferManager = null)
{
this._bufferManager = bufferManager ?? new DynamicBufferManager();
this._assemblyBuffer = this._bufferManager.GetBuffer(BufferConfig.INITIAL_BUFFER_SIZE);
}
/// <summary>
/// Gets whether there is incomplete data waiting for more chunks.
/// </summary>
public bool HasIncompleteData => _currentDataLength > 0;
/// <summary>
/// Gets the current amount of data in the assembly buffer.
/// </summary>
public int CurrentDataLength => _currentDataLength;
/// <summary>
/// Gets whether the header has been parsed and we're waiting for content.
/// </summary>
public bool IsWaitingForContent => _headerParsed && _expectedContentLength > 0;
/// <summary>
/// Adds new data to the reassembly buffer.
/// </summary>
/// <param name="data">The data chunk to add</param>
/// <param name="length">The length of valid data in the chunk</param>
public void AddData(byte[] data, int length)
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(MessageReassembler));
}
if (data == null || length <= 0)
{
return;
}
if (length > data.Length)
{
throw new ArgumentException("Length cannot exceed data array size", nameof(length));
}
_totalDataChunksProcessed++;
// Ensure we have enough space in the assembly buffer
int requiredSize = _currentDataLength + length;
if (requiredSize > _assemblyBuffer.Length)
{
_bufferManager.ResizeBuffer(ref _assemblyBuffer, _currentDataLength, requiredSize);
}
// Copy new data to assembly buffer
Array.Copy(data, 0, _assemblyBuffer, _currentDataLength, length);
_currentDataLength += length;
}
/// <summary>
/// Attempts to extract complete messages from the assembly buffer.
/// </summary>
/// <returns>Array of complete JSON messages, or empty array if none available</returns>
public string[] ExtractCompleteMessages()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(MessageReassembler));
}
var completeMessages = new List<string>();
while (_currentDataLength > 0)
{
string extractedMessage = TryExtractSingleMessage();
if (extractedMessage == null)
{
// No complete message available
break;
}
completeMessages.Add(extractedMessage);
_totalMessagesReassembled++;
}
return completeMessages.ToArray();
}
/// <summary>
/// Attempts to extract a single complete message from the buffer.
/// </summary>
/// <returns>The extracted JSON message, or null if not complete</returns>
private string TryExtractSingleMessage()
{
// If we haven't parsed the header yet, try to parse it
if (!_headerParsed)
{
if (!TryParseHeader())
{
// Header not complete yet
return null;
}
}
// Early detection of abnormal states - throw exceptions immediately
if (_expectedContentLength < 0)
{
throw new InvalidOperationException($"Invalid Content-Length value: {_expectedContentLength}. Message framing is corrupted.");
}
if (_headerLength < 0)
{
throw new InvalidOperationException($"Invalid header length: {_headerLength}. Message framing is corrupted.");
}
if (_expectedContentLength > BufferConfig.MAX_MESSAGE_SIZE)
{
throw new InvalidOperationException($"Content-Length {_expectedContentLength} exceeds maximum message size {BufferConfig.MAX_MESSAGE_SIZE}.");
}
// Check if we have the complete message
int expectedTotalLength = _headerLength + _expectedContentLength;
if (_currentDataLength < expectedTotalLength)
{
// Message not complete yet
return null;
}
// Validate buffer bounds before extraction
if (_headerLength + _expectedContentLength > _currentDataLength)
{
throw new InvalidOperationException($"Buffer underflow: trying to read {_expectedContentLength} bytes at offset {_headerLength}, but only {_currentDataLength} bytes available.");
}
// Extract the JSON content with proper error handling
string jsonContent;
try
{
jsonContent = Encoding.UTF8.GetString(_assemblyBuffer, _headerLength, _expectedContentLength);
}
catch (ArgumentException ex)
{
throw new InvalidOperationException($"Invalid UTF-8 sequence detected in message content at offset {_headerLength}, length {_expectedContentLength}: {ex.Message}", ex);
}
// Log before resetting state
// Remove the processed message from the buffer
RemoveProcessedData(expectedTotalLength);
// Reset parsing state for next message
ResetParsingState();
return jsonContent;
}
/// <summary>
/// Attempts to parse the Content-Length header from the current buffer.
/// </summary>
/// <returns>True if header was successfully parsed, false otherwise</returns>
private bool TryParseHeader()
{
var frameParser = new FrameParser();
bool parseResult = frameParser.TryParseFrame(_assemblyBuffer, _currentDataLength,
out _expectedContentLength, out _headerLength);
if (parseResult)
{
// Early validation of parsed values
if (_expectedContentLength < 0)
{
throw new InvalidOperationException($"FrameParser returned invalid Content-Length: {_expectedContentLength}");
}
if (_headerLength < 0)
{
throw new InvalidOperationException($"FrameParser returned invalid header length: {_headerLength}");
}
_headerParsed = true;
}
return parseResult;
}
/// <summary>
/// Removes processed data from the beginning of the assembly buffer.
/// </summary>
/// <param name="bytesToRemove">Number of bytes to remove from the beginning</param>
private void RemoveProcessedData(int bytesToRemove)
{
if (bytesToRemove <= 0 || bytesToRemove > _currentDataLength)
{
return;
}
// Move remaining data to the beginning of the buffer
int remainingDataLength = _currentDataLength - bytesToRemove;
if (remainingDataLength > 0)
{
Array.Copy(_assemblyBuffer, bytesToRemove, _assemblyBuffer, 0, remainingDataLength);
}
_currentDataLength = remainingDataLength;
}
/// <summary>
/// Resets the parsing state for the next message.
/// </summary>
private void ResetParsingState()
{
_headerParsed = false;
_expectedContentLength = -1;
_headerLength = -1;
}
/// <summary>
/// Clears all data from the assembly buffer.
/// Useful for error recovery or connection reset.
/// </summary>
public void Clear()
{
if (_disposed)
{
return;
}
_currentDataLength = 0;
ResetParsingState();
}
/// <summary>
/// Gets statistics about the reassembler's performance.
/// </summary>
/// <returns>Statistics about message reassembly</returns>
public MessageReassemblerStats GetStats()
{
return new MessageReassemblerStats
{
TotalMessagesReassembled = _totalMessagesReassembled,
TotalDataChunksProcessed = _totalDataChunksProcessed,
CurrentBufferSize = _assemblyBuffer?.Length ?? 0,
CurrentDataLength = _currentDataLength,
HasIncompleteData = HasIncompleteData,
IsWaitingForContent = IsWaitingForContent,
ExpectedContentLength = _expectedContentLength,
HeaderLength = _headerLength
};
}
/// <summary>
/// Validates the current state of the reassembler.
/// </summary>
/// <returns>True if state is valid, false if cleanup is needed</returns>
public bool ValidateState()
{
if (_disposed)
{
return false;
}
// Check for reasonable buffer size - throw exception instead of silently clearing
if (_currentDataLength > BufferConfig.MAX_MESSAGE_SIZE)
{
throw new InvalidOperationException($"Buffer size {_currentDataLength} exceeds maximum message size {BufferConfig.MAX_MESSAGE_SIZE}. Data corruption detected.");
}
// Check for stale incomplete data - throw exception for corrupted state
if (_headerParsed && _expectedContentLength > 0)
{
int expectedTotalLength = _headerLength + _expectedContentLength;
if (expectedTotalLength > BufferConfig.MAX_MESSAGE_SIZE)
{
throw new InvalidOperationException($"Expected message size {expectedTotalLength} exceeds maximum {BufferConfig.MAX_MESSAGE_SIZE}. Message framing is corrupted.");
}
}
// Validate consistency of parsing state
if (_headerParsed && (_expectedContentLength < 0 || _headerLength < 0))
{
throw new InvalidOperationException($"Inconsistent parsing state: headerParsed={_headerParsed}, expectedContentLength={_expectedContentLength}, headerLength={_headerLength}");
}
return true;
}
/// <summary>
/// Gets a preview of the current buffer content for debugging.
/// </summary>
/// <param name="maxLength">Maximum length of preview</param>
/// <returns>String representation of buffer content</returns>
public string GetBufferPreview(int maxLength = 100)
{
if (_disposed || _currentDataLength == 0)
{
return string.Empty;
}
// Safe UTF-8 decoding with proper error handling
int previewLength = Math.Min(_currentDataLength, maxLength);
string preview;
try
{
preview = Encoding.UTF8.GetString(_assemblyBuffer, 0, previewLength);
}
catch (ArgumentException ex)
{
throw new InvalidOperationException($"Invalid UTF-8 sequence detected in buffer preview (length {previewLength}): {ex.Message}", ex);
}
if (_currentDataLength > maxLength)
{
preview += "...";
}
return preview;
}
/// <summary>
/// Releases all resources used by the MessageReassembler.
/// </summary>
public void Dispose()
{
if (!_disposed)
{
if (_assemblyBuffer != null)
{
_bufferManager.ReturnBuffer(_assemblyBuffer);
_assemblyBuffer = null;
}
_bufferManager?.Dispose();
_disposed = true;
}
}
}
/// <summary>
/// Statistics about message reassembler performance and state.
/// </summary>
public class MessageReassemblerStats
{
/// <summary>
/// Total number of complete messages reassembled.
/// </summary>
public int TotalMessagesReassembled { get; set; }
/// <summary>
/// Total number of data chunks processed.
/// </summary>
public int TotalDataChunksProcessed { get; set; }
/// <summary>
/// Current size of the assembly buffer.
/// </summary>
public int CurrentBufferSize { get; set; }
/// <summary>
/// Current amount of data in the buffer.
/// </summary>
public int CurrentDataLength { get; set; }
/// <summary>
/// Whether there is incomplete data in the buffer.
/// </summary>
public bool HasIncompleteData { get; set; }
/// <summary>
/// Whether the reassembler is waiting for content after parsing header.
/// </summary>
public bool IsWaitingForContent { get; set; }
/// <summary>
/// Expected content length from parsed header.
/// </summary>
public int ExpectedContentLength { get; set; }
/// <summary>
/// Length of the parsed header.
/// </summary>
public int HeaderLength { get; set; }
/// <summary>
/// Returns a formatted string representation of the statistics.
/// </summary>
public override string ToString()
{
return $"MessageReassembler Stats: Messages={TotalMessagesReassembled}, " +
$"Chunks={TotalDataChunksProcessed}, BufferSize={CurrentDataLength}/{CurrentBufferSize}, " +
$"Incomplete={HasIncompleteData}, WaitingForContent={IsWaitingForContent}";
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b533ba47e32a443d9a3a5b829a724c76
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,21 @@
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Reason for server shutdown, sent to TypeScript MCP server for message differentiation.
/// Values must match TypeScript ServerShutdownReason constants.
/// </summary>
public enum ServerShutdownReason
{
/// <summary>
/// Server is shutting down due to domain reload (compilation).
/// TypeScript side should show "waiting" message.
/// </summary>
DomainReload,
/// <summary>
/// Server is shutting down due to editor quit.
/// TypeScript side should show "server not running" error.
/// </summary>
EditorQuit
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4a899d5a46dc7492dbc927fe8659e220
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: