chore(unity): 固定Unity CLI Loop本地包并移除UnitySkills依赖
This commit is contained in:
@@ -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}%";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1f086a0e0465f472c8d4921c21db1149
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 875d88de254b6418bb2d15b0f1dabb4b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ef5a31eaf3f994a32aaa20b307bcf510
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1693b2ef898dc48bdb0448a656e9452b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bbceee50635c848f18192ed5ce5280a6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b533ba47e32a443d9a3a5b829a724c76
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4a899d5a46dc7492dbc927fe8659e220
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user