fix: comprehensive performance optimizations, claude code config, and stability improvements (issue #577) (#595)

* fix: reduce per-frame GC allocations causing editor hitches (issue #577)

Eliminate memory allocations that occurred every frame, which triggered
garbage collection spikes (~28ms) approximately every second.

Changes:
- EditorStateCache: Skip BuildSnapshot() entirely when state unchanged
  (check BEFORE building). Increased poll interval from 0.25s to 1.0s.
  Cache DeepClone() results to avoid allocations on GetSnapshot().

- TransportCommandDispatcher: Early exit before lock/list allocation
  when Pending.Count == 0, eliminating per-frame allocations when idle.

- StdioBridgeHost: Same early exit pattern for commandQueue.

- MCPForUnityEditorWindow: Throttle OnEditorUpdate to 2-second intervals
  instead of every frame, preventing expensive socket checks 60+/sec.

Fixes GitHub issue #577: High performance impact even when MCP server is off

* fix: prevent multiple domain reloads when calling refresh_unity (issue #577)

Root Cause:
- send_command() had a hardcoded retry loop (min 6 attempts) on connection errors
- Each retry resent the refresh_unity command, causing Unity to reload 6 times
- retry_on_reload=False only controlled reload-state retries, not connection retries

The Fix:
1. Unity C# (MCPForUnity/Editor):
   - Added --reinstall flag to uvx commands in dev mode
   - Ensures local development changes are picked up by uvx/Claude Code
   - Applies to all client configurators (Claude Code, Codex, etc.)

2. Python Server (Server/src):
   - Added max_attempts parameter to send_command()
   - Pass max_attempts=0 when retry_on_reload=False
   - Fixed type handling in refresh_unity.py (handle MCPResponse objects)
   - Added timeout to connection error recovery conditions
   - Recovery logic now returns success instead of error to prevent client retries

Changes:
- MCPForUnity/Editor: Added --reinstall to dev mode uvx commands
- Server/refresh_unity.py: Fixed type handling, improved error recovery
- Server/unity_connection.py: Added max_attempts param, disable retries when retry_on_reload=False

Result: refresh_unity with compile=request now triggers only 1 domain reload instead of 6

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: UI and server stability improvements

Unity Editor (C#):
- Fix "Resuming..." stuck state when manually clicking End Session
  Clear ResumeStdioAfterReload and ResumeHttpAfterReload flags in
  OnConnectionToggleClicked and EndOrphanedSessionAsync to prevent
  UI from getting stuck showing "Resuming..." with disabled button
- Remove unsupported --reinstall flag from all uvx command builders
  uvx does not support --reinstall and shows warning when used
  Use --no-cache --refresh instead for dev mode cache busting

Python Server:
- Add "aborted" to connection error patterns in refresh_unity
  Handle WinError 10053 (connection aborted) gracefully during
  Unity domain reload, treating it as expected behavior
- Add WindowsSafeRotatingFileHandler to suppress log rotation errors
  Windows file locking prevents log rotation when file is open by
  another process; catch PermissionError to avoid noisy stack traces
- Fix packaging: add py-modules = ["main"] to pyproject.toml
  setuptools.packages.find only discovers packages (directories with
  __init__.py), must explicitly list standalone module files

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* docs: improve refresh_unity connection loss handling documentation

Add detailed comments and logging to clarify why connection loss during
compile is treated as success (expected domain reload behavior, not failure).
This addresses PR feedback about potentially masking real connection errors.

The logic is intentional and correct:
- Connection loss only treated as success when compile='request'
- Domain reload causing disconnect is expected Unity behavior
- Subsequent wait_for_ready loop validates Unity becomes ready
- Prevents multiple domain reload loops (issue #577)

Added logging for observability:
- Info log when expected disconnect detected
- Warning log for non-recoverable errors

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: add missing logger import in refresh_unity

Missing logger import causes NameError at runtime when connection
loss handling paths are triggered (lines 82 and 91).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: address code review feedback on thread safety and semantics

Addresses four issues raised in code review:

1. EditorStateCache.GetSnapshot() - Remove shared cached clone
   - Revert to always returning fresh DeepClone() to prevent mutation bugs
   - Main GC optimization remains: state-change detection prevents
     unnecessary _cached rebuilds (the expensive operation)
   - Removed _cachedClone and _cachedCloneSequence fields

2. refresh_unity.py - Fix blocking reason terminology mismatch
   - Changed "asset_refresh" to "asset_import" to match activityPhase
     values from EditorStateCache.cs
   - Ensures asset import is correctly detected as blocking state

3. TransportCommandDispatcher - Fix unsynchronized Count access
   - Moved Pending.Count check inside PendingLock
   - Prevents data races and InvalidOperationException from concurrent
     dictionary access

4. StdioBridgeHost - Fix unsynchronized Count access
   - Moved commandQueue.Count check inside lockObj
   - Ensures all collection access is properly serialized

All changes maintain the GC allocation optimizations while fixing
thread safety violations and semantic contract changes.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: address code review feedback on thread safety and timeout handling

- refresh_unity.py: Track readiness explicitly and return failure on timeout
  instead of silently returning success when wait loop exits without confirming
  ready_for_tools=true

- McpClientConfiguratorBase.cs: Add thread safety guard for Configure() call
  in CheckStatusWithProjectDir(). Changed default attemptAutoRewrite to false
  and added runtime check to prevent calling Configure() from background threads.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
main
dsarno 2026-01-20 18:11:25 -08:00 committed by GitHub
parent cd10472bc6
commit 8eb684ddc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 562 additions and 92 deletions

View File

@ -1,30 +1,27 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Models;
namespace MCPForUnity.Editor.Clients.Configurators
{
public class ClaudeCodeConfigurator : JsonFileMcpConfigurator
/// <summary>
/// Claude Code configurator using the CLI-based registration (claude mcp add/remove).
/// This integrates with Claude Code's native MCP management.
/// </summary>
public class ClaudeCodeConfigurator : ClaudeCliMcpConfigurator
{
public ClaudeCodeConfigurator() : base(new McpClient
{
name = "Claude Code",
windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude.json"),
macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude.json"),
linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude.json"),
SupportsHttpTransport = true,
HttpUrlProperty = "url", // Claude Code uses "url" for HTTP servers
IsVsCodeLayout = false, // Claude Code uses standard mcpServers layout
})
{ }
public override IList<string> GetInstallationSteps() => new List<string>
{
"Open your project in Claude Code",
"Click Configure in MCP for Unity (or manually edit ~/.claude.json)",
"The MCP server will be added to the global mcpServers section",
"Restart Claude Code to apply changes"
"Ensure Claude CLI is installed (comes with Claude Code)",
"Click Register to add UnityMCP via 'claude mcp add'",
"The server will be automatically available in Claude Code",
"Use Unregister to remove via 'claude mcp remove'"
};
}
}

View File

@ -352,8 +352,11 @@ namespace MCPForUnity.Editor.Clients
/// Internal thread-safe version of CheckStatus.
/// Can be called from background threads because all main-thread-only values are passed as parameters.
/// projectDir, useHttpTransport, and claudePath are REQUIRED (non-nullable) to enforce thread safety at compile time.
/// NOTE: attemptAutoRewrite is NOT fully thread-safe because Configure() requires the main thread.
/// When called from a background thread, pass attemptAutoRewrite=false and handle re-registration
/// on the main thread based on the returned status.
/// </summary>
internal McpStatus CheckStatusWithProjectDir(string projectDir, bool useHttpTransport, string claudePath, bool attemptAutoRewrite = true)
internal McpStatus CheckStatusWithProjectDir(string projectDir, bool useHttpTransport, string claudePath, bool attemptAutoRewrite = false)
{
try
{
@ -391,7 +394,7 @@ namespace MCPForUnity.Editor.Clients
}
catch { }
// Check if UnityMCP exists
// Check if UnityMCP exists (handles both "UnityMCP" and legacy "unityMCP")
if (ExecPath.TryRun(claudePath, "mcp list", projectDir, out var listStdout, out var listStderr, 10000, pathPrepend))
{
if (!string.IsNullOrEmpty(listStdout) && listStdout.IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0)
@ -401,7 +404,11 @@ namespace MCPForUnity.Editor.Clients
bool currentUseHttp = useHttpTransport;
// Get detailed info about the registration to check transport type
if (ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out var getStdout, out var getStderr, 7000, pathPrepend))
// Try both "UnityMCP" and "unityMCP" (legacy naming)
string getStdout = null, getStderr = null;
bool gotInfo = ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out getStdout, out getStderr, 7000, pathPrepend)
|| ExecPath.TryRun(claudePath, "mcp get unityMCP", projectDir, out getStdout, out getStderr, 7000, pathPrepend);
if (gotInfo)
{
// Parse the output to determine registered transport mode
// The CLI output format contains "Type: http" or "Type: stdio"
@ -409,16 +416,62 @@ namespace MCPForUnity.Editor.Clients
bool registeredWithStdio = getStdout.Contains("Type: stdio", StringComparison.OrdinalIgnoreCase);
// Check for transport mismatch
if ((currentUseHttp && registeredWithStdio) || (!currentUseHttp && registeredWithHttp))
bool hasTransportMismatch = (currentUseHttp && registeredWithStdio) || (!currentUseHttp && registeredWithHttp);
// For stdio transport, also check package version
bool hasVersionMismatch = false;
string configuredPackageSource = null;
string expectedPackageSource = null;
if (registeredWithStdio)
{
string registeredTransport = registeredWithHttp ? "HTTP" : "stdio";
string currentTransport = currentUseHttp ? "HTTP" : "stdio";
string errorMsg = $"Transport mismatch: Claude Code is registered with {registeredTransport} but current setting is {currentTransport}. Click Configure to re-register.";
expectedPackageSource = AssetPathUtility.GetMcpServerPackageSource();
configuredPackageSource = ExtractPackageSourceFromCliOutput(getStdout);
hasVersionMismatch = !string.IsNullOrEmpty(configuredPackageSource) &&
!string.Equals(configuredPackageSource, expectedPackageSource, StringComparison.OrdinalIgnoreCase);
}
// If there's any mismatch and auto-rewrite is enabled, re-register
if (hasTransportMismatch || hasVersionMismatch)
{
// Configure() requires main thread (accesses EditorPrefs, Application.dataPath)
// Only attempt auto-rewrite if we're on the main thread
bool isMainThread = System.Threading.Thread.CurrentThread.ManagedThreadId == 1;
if (attemptAutoRewrite && isMainThread)
{
string reason = hasTransportMismatch
? $"Transport mismatch (registered: {(registeredWithHttp ? "HTTP" : "stdio")}, expected: {(currentUseHttp ? "HTTP" : "stdio")})"
: $"Package version mismatch (registered: {configuredPackageSource}, expected: {expectedPackageSource})";
McpLog.Info($"{reason}. Re-registering...");
try
{
// Force re-register by ensuring status is not Configured (which would toggle to Unregister)
client.SetStatus(McpStatus.IncorrectPath);
Configure();
return client.status;
}
catch (Exception ex)
{
McpLog.Warn($"Auto-reregister failed: {ex.Message}");
client.SetStatus(McpStatus.IncorrectPath, $"Configuration mismatch. Click Configure to re-register.");
return client.status;
}
}
else
{
if (hasTransportMismatch)
{
string errorMsg = $"Transport mismatch: Claude Code is registered with {(registeredWithHttp ? "HTTP" : "stdio")} but current setting is {(currentUseHttp ? "HTTP" : "stdio")}. Click Configure to re-register.";
client.SetStatus(McpStatus.Error, errorMsg);
McpLog.Warn(errorMsg);
}
else
{
client.SetStatus(McpStatus.IncorrectPath, $"Package version mismatch: registered with '{configuredPackageSource}' but current version is '{expectedPackageSource}'.");
}
return client.status;
}
}
}
client.SetStatus(McpStatus.Configured);
return client.status;
@ -447,6 +500,85 @@ namespace MCPForUnity.Editor.Clients
}
}
/// <summary>
/// Thread-safe version of Configure that uses pre-captured main-thread values.
/// All parameters must be captured on the main thread before calling this method.
/// </summary>
public void ConfigureWithCapturedValues(
string projectDir, string claudePath, string pathPrepend,
bool useHttpTransport, string httpUrl,
string uvxPath, string gitUrl, string packageName, bool shouldForceRefresh)
{
if (client.status == McpStatus.Configured)
{
UnregisterWithCapturedValues(projectDir, claudePath, pathPrepend);
}
else
{
RegisterWithCapturedValues(projectDir, claudePath, pathPrepend,
useHttpTransport, httpUrl, uvxPath, gitUrl, packageName, shouldForceRefresh);
}
}
/// <summary>
/// Thread-safe registration using pre-captured values.
/// </summary>
private void RegisterWithCapturedValues(
string projectDir, string claudePath, string pathPrepend,
bool useHttpTransport, string httpUrl,
string uvxPath, string gitUrl, string packageName, bool shouldForceRefresh)
{
if (string.IsNullOrEmpty(claudePath))
{
throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first.");
}
string args;
if (useHttpTransport)
{
args = $"mcp add --transport http UnityMCP {httpUrl}";
}
else
{
// Note: --reinstall is not supported by uvx, use --no-cache --refresh instead
string devFlags = shouldForceRefresh ? "--no-cache --refresh " : string.Empty;
args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}--from \"{gitUrl}\" {packageName}";
}
// Remove any existing registrations - handle both "UnityMCP" and "unityMCP" (legacy)
McpLog.Info("Removing any existing UnityMCP registrations before adding...");
ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out _, out _, 7000, pathPrepend);
ExecPath.TryRun(claudePath, "mcp remove unityMCP", projectDir, out _, out _, 7000, pathPrepend);
// Now add the registration
if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend))
{
throw new InvalidOperationException($"Failed to register with Claude Code:\n{stderr}\n{stdout}");
}
McpLog.Info($"Successfully registered with Claude Code using {(useHttpTransport ? "HTTP" : "stdio")} transport.");
client.SetStatus(McpStatus.Configured);
}
/// <summary>
/// Thread-safe unregistration using pre-captured values.
/// </summary>
private void UnregisterWithCapturedValues(string projectDir, string claudePath, string pathPrepend)
{
if (string.IsNullOrEmpty(claudePath))
{
throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first.");
}
// Remove both "UnityMCP" and "unityMCP" (legacy naming)
McpLog.Info("Removing all UnityMCP registrations...");
ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out _, out _, 7000, pathPrepend);
ExecPath.TryRun(claudePath, "mcp remove unityMCP", projectDir, out _, out _, 7000, pathPrepend);
McpLog.Info("MCP server successfully unregistered from Claude Code.");
client.SetStatus(McpStatus.NotConfigured);
}
private void Register()
{
var pathService = MCPServiceLocator.Paths;
@ -468,6 +600,7 @@ namespace MCPForUnity.Editor.Clients
{
var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts();
// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
// Note: --reinstall is not supported by uvx, use --no-cache --refresh instead
string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty;
args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}--from \"{gitUrl}\" {packageName}";
}
@ -496,17 +629,10 @@ namespace MCPForUnity.Editor.Clients
}
catch { }
// Check if UnityMCP already exists and remove it first to ensure clean registration
// This ensures we always use the current transport mode setting
bool serverExists = ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out _, out _, 7000, pathPrepend);
if (serverExists)
{
McpLog.Info("Existing UnityMCP registration found - removing to ensure transport mode is up-to-date");
if (!ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out var removeStdout, out var removeStderr, 10000, pathPrepend))
{
McpLog.Warn($"Failed to remove existing UnityMCP registration: {removeStderr}. Attempting to register anyway...");
}
}
// Remove any existing registrations - handle both "UnityMCP" and "unityMCP" (legacy)
McpLog.Info("Removing any existing UnityMCP registrations before adding...");
ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out _, out _, 7000, pathPrepend);
ExecPath.TryRun(claudePath, "mcp remove unityMCP", projectDir, out _, out _, 7000, pathPrepend);
// Now add the registration with the current transport mode
if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend))
@ -542,26 +668,13 @@ namespace MCPForUnity.Editor.Clients
pathPrepend = "/usr/local/bin:/usr/bin:/bin";
}
bool serverExists = ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out _, out _, 7000, pathPrepend);
// Remove both "UnityMCP" and "unityMCP" (legacy naming)
McpLog.Info("Removing all UnityMCP registrations...");
ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out _, out _, 7000, pathPrepend);
ExecPath.TryRun(claudePath, "mcp remove unityMCP", projectDir, out _, out _, 7000, pathPrepend);
if (!serverExists)
{
client.SetStatus(McpStatus.NotConfigured);
McpLog.Info("No MCP for Unity server found - already unregistered.");
return;
}
if (ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out var stdout, out var stderr, 10000, pathPrepend))
{
McpLog.Info("MCP server successfully unregistered from Claude Code.");
}
else
{
throw new InvalidOperationException($"Failed to unregister: {stderr}");
}
client.SetStatus(McpStatus.NotConfigured);
// Status is already set - no need for blocking CheckStatus() call
}
public override string GetManualSnippet()
@ -577,7 +690,7 @@ namespace MCPForUnity.Editor.Clients
"# Unregister the MCP server:\n" +
"claude mcp remove UnityMCP\n\n" +
"# List registered servers:\n" +
"claude mcp list # Only works when claude is run in the project's directory";
"claude mcp list";
}
if (string.IsNullOrEmpty(uvxPath))
@ -587,6 +700,7 @@ namespace MCPForUnity.Editor.Clients
string packageSource = AssetPathUtility.GetMcpServerPackageSource();
// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
// Note: --reinstall is not supported by uvx, use --no-cache --refresh instead
string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty;
return "# Register the MCP server with Claude Code:\n" +
@ -594,7 +708,7 @@ namespace MCPForUnity.Editor.Clients
"# Unregister the MCP server:\n" +
"claude mcp remove UnityMCP\n\n" +
"# List registered servers:\n" +
"claude mcp list # Only works when claude is run in the project's directory";
"claude mcp list";
}
public override IList<string> GetInstallationSteps() => new List<string>
@ -603,5 +717,51 @@ namespace MCPForUnity.Editor.Clients
"Use Register to add UnityMCP (or run claude mcp add UnityMCP)",
"Restart Claude Code"
};
/// <summary>
/// Extracts the package source (--from argument value) from claude mcp get output.
/// The output format includes args like: --from "mcpforunityserver==9.0.1"
/// </summary>
private static string ExtractPackageSourceFromCliOutput(string cliOutput)
{
if (string.IsNullOrEmpty(cliOutput))
return null;
// Look for --from followed by the package source
// The CLI output may have it quoted or unquoted
int fromIndex = cliOutput.IndexOf("--from", StringComparison.OrdinalIgnoreCase);
if (fromIndex < 0)
return null;
// Move past "--from" and any whitespace
int startIndex = fromIndex + 6;
while (startIndex < cliOutput.Length && char.IsWhiteSpace(cliOutput[startIndex]))
startIndex++;
if (startIndex >= cliOutput.Length)
return null;
// Check if value is quoted
char quoteChar = cliOutput[startIndex];
if (quoteChar == '"' || quoteChar == '\'')
{
startIndex++;
int endIndex = cliOutput.IndexOf(quoteChar, startIndex);
if (endIndex > startIndex)
return cliOutput.Substring(startIndex, endIndex - startIndex);
}
else
{
// Unquoted - read until whitespace or end of line
int endIndex = startIndex;
while (endIndex < cliOutput.Length && !char.IsWhiteSpace(cliOutput[endIndex]))
endIndex++;
if (endIndex > startIndex)
return cliOutput.Substring(startIndex, endIndex - startIndex);
}
return null;
}
}
}

View File

@ -198,6 +198,7 @@ namespace MCPForUnity.Editor.Helpers
/// Determines whether uvx should use --no-cache --refresh flags.
/// Returns true if DevModeForceServerRefresh is enabled OR if the server URL is a local path.
/// Local paths (file:// or absolute) always need fresh builds to avoid stale uvx cache.
/// Note: --reinstall is not supported by uvx and will cause a warning.
/// </summary>
public static bool ShouldForceUvxRefresh()
{

View File

@ -21,6 +21,7 @@ namespace MCPForUnity.Editor.Helpers
{
if (args == null) return;
// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
// Note: --reinstall is not supported by uvx, use --no-cache --refresh instead
if (!AssetPathUtility.ShouldForceUvxRefresh()) return;
args.Add(new TomlString { Value = "--no-cache" });
args.Add(new TomlString { Value = "--refresh" });

View File

@ -159,7 +159,8 @@ namespace MCPForUnity.Editor.Helpers
private static IList<string> BuildUvxArgs(string fromUrl, string packageName)
{
// Dev mode: force a fresh install/resolution (avoids stale cached builds while iterating).
// `--no-cache` is the key flag; `--refresh` ensures metadata is revalidated.
// `--no-cache` avoids reading from cache; `--refresh` ensures metadata is revalidated.
// Note: --reinstall is not supported by uvx and will cause a warning.
// Keep ordering consistent with other uvx builders: dev flags first, then --from <url>, then package name.
var args = new List<string>();

View File

@ -54,10 +54,24 @@ namespace MCPForUnity.Editor.Migrations
{
try
{
if (!ConfigUsesStdIo(configurator.Client))
if (!configurator.SupportsAutoConfigure)
continue;
if (!configurator.SupportsAutoConfigure)
// Handle CLI-based configurators (e.g., Claude Code CLI)
// CheckStatus with attemptAutoRewrite=true will auto-reregister if version mismatch
if (configurator is ClaudeCliMcpConfigurator cliConfigurator)
{
var previousStatus = configurator.Status;
configurator.CheckStatus(attemptAutoRewrite: true);
if (configurator.Status != previousStatus)
{
touchedAny = true;
}
continue;
}
// Handle JSON file-based configurators
if (!ConfigUsesStdIo(configurator.Client))
continue;
MCPServiceLocator.Client.ConfigureClient(configurator);

View File

@ -30,7 +30,17 @@ namespace MCPForUnity.Editor.Services
private static long? _domainReloadAfterUnixMs;
private static double _lastUpdateTimeSinceStartup;
private const double MinUpdateIntervalSeconds = 0.25;
private const double MinUpdateIntervalSeconds = 1.0; // Reduced frequency: 1s instead of 0.25s
// State tracking to detect when snapshot actually changes (checked BEFORE building)
private static string _lastTrackedScenePath;
private static string _lastTrackedSceneName;
private static bool _lastTrackedIsFocused;
private static bool _lastTrackedIsPlaying;
private static bool _lastTrackedIsPaused;
private static bool _lastTrackedIsUpdating;
private static bool _lastTrackedTestsRunning;
private static string _lastTrackedActivityPhase;
private static JObject _cached;
@ -263,15 +273,77 @@ namespace MCPForUnity.Editor.Services
{
// Throttle to reduce overhead while keeping the snapshot fresh enough for polling clients.
double now = EditorApplication.timeSinceStartup;
if (now - _lastUpdateTimeSinceStartup < MinUpdateIntervalSeconds)
{
// Still update on compilation edge transitions to keep timestamps meaningful.
// Use GetActualIsCompiling() to avoid Play mode false positives (issue #582)
bool isCompiling = GetActualIsCompiling();
if (isCompiling == _lastIsCompiling)
// Check for compilation edge transitions (always update on these)
bool compilationEdge = isCompiling != _lastIsCompiling;
if (!compilationEdge && now - _lastUpdateTimeSinceStartup < MinUpdateIntervalSeconds)
{
return;
}
// Fast state-change detection BEFORE building snapshot.
// This avoids the expensive BuildSnapshot() call entirely when nothing changed.
// These checks are much cheaper than building a full JSON snapshot.
var scene = EditorSceneManager.GetActiveScene();
string scenePath = string.IsNullOrEmpty(scene.path) ? null : scene.path;
string sceneName = scene.name ?? string.Empty;
bool isFocused = InternalEditorUtility.isApplicationActive;
bool isPlaying = EditorApplication.isPlaying;
bool isPaused = EditorApplication.isPaused;
bool isUpdating = EditorApplication.isUpdating;
bool testsRunning = TestRunStatus.IsRunning;
var activityPhase = "idle";
if (testsRunning)
{
activityPhase = "running_tests";
}
else if (isCompiling)
{
activityPhase = "compiling";
}
else if (_domainReloadPending)
{
activityPhase = "domain_reload";
}
else if (isUpdating)
{
activityPhase = "asset_import";
}
else if (EditorApplication.isPlayingOrWillChangePlaymode)
{
activityPhase = "playmode_transition";
}
bool hasChanges = compilationEdge
|| _lastTrackedScenePath != scenePath
|| _lastTrackedSceneName != sceneName
|| _lastTrackedIsFocused != isFocused
|| _lastTrackedIsPlaying != isPlaying
|| _lastTrackedIsPaused != isPaused
|| _lastTrackedIsUpdating != isUpdating
|| _lastTrackedTestsRunning != testsRunning
|| _lastTrackedActivityPhase != activityPhase;
if (!hasChanges)
{
// No state change - skip the expensive BuildSnapshot entirely.
// This is the key optimization that prevents the 28ms GC spikes.
return;
}
// Update tracked state
_lastTrackedScenePath = scenePath;
_lastTrackedSceneName = sceneName;
_lastTrackedIsFocused = isFocused;
_lastTrackedIsPlaying = isPlaying;
_lastTrackedIsPaused = isPaused;
_lastTrackedIsUpdating = isUpdating;
_lastTrackedTestsRunning = testsRunning;
_lastTrackedActivityPhase = activityPhase;
_lastUpdateTimeSinceStartup = now;
ForceUpdate("tick");
@ -425,6 +497,10 @@ namespace MCPForUnity.Editor.Services
{
_cached = BuildSnapshot("rebuild");
}
// Always return a fresh clone to prevent mutation bugs.
// The main GC optimization comes from state-change detection (OnUpdate)
// which prevents unnecessary _cached rebuilds, not from caching the clone.
return (JObject)_cached.DeepClone();
}
}

View File

@ -1310,6 +1310,7 @@ namespace MCPForUnity.Editor.Services
}
// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
// Note: --reinstall is not supported by uvx, use --no-cache --refresh instead
string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty;
string args = string.IsNullOrEmpty(fromUrl)
? $"{devFlags}{packageName} --transport http --http-url {httpUrl}"

View File

@ -229,6 +229,12 @@ namespace MCPForUnity.Editor.Services.Transport
lock (PendingLock)
{
// Early exit inside lock to prevent per-frame List allocations (GitHub issue #577)
if (Pending.Count == 0)
{
return;
}
ready = new List<(string, PendingCommand)>(Pending.Count);
foreach (var kvp in Pending)
{

View File

@ -821,6 +821,12 @@ namespace MCPForUnity.Editor.Services.Transport.Transports
List<(string id, QueuedCommand command)> work;
lock (lockObj)
{
// Early exit inside lock to prevent per-frame List allocations (GitHub issue #577)
if (commandQueue.Count == 0)
{
return;
}
work = new List<(string, QueuedCommand)>(commandQueue.Count);
foreach (var kvp in commandQueue)
{

View File

@ -221,6 +221,13 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
var client = configurators[selectedClientIndex];
// Handle CLI configurators asynchronously
if (client is ClaudeCliMcpConfigurator)
{
ConfigureClaudeCliAsync(client);
return;
}
try
{
MCPServiceLocator.Client.ConfigureClient(client);
@ -237,6 +244,92 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
}
}
private void ConfigureClaudeCliAsync(IMcpClientConfigurator client)
{
if (statusRefreshInFlight.Contains(client))
return;
statusRefreshInFlight.Add(client);
bool isCurrentlyConfigured = client.Status == McpStatus.Configured;
ApplyStatusToUi(client, showChecking: true, customMessage: isCurrentlyConfigured ? "Unregistering..." : "Registering...");
// Capture ALL main-thread-only values before async task
string projectDir = Path.GetDirectoryName(Application.dataPath);
bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath();
string httpUrl = HttpEndpointUtility.GetMcpRpcUrl();
var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts();
bool shouldForceRefresh = AssetPathUtility.ShouldForceUvxRefresh();
// Compute pathPrepend on main thread
string pathPrepend = null;
if (Application.platform == RuntimePlatform.OSXEditor)
pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin";
else if (Application.platform == RuntimePlatform.LinuxEditor)
pathPrepend = "/usr/local/bin:/usr/bin:/bin";
try
{
string claudeDir = Path.GetDirectoryName(claudePath);
if (!string.IsNullOrEmpty(claudeDir))
pathPrepend = string.IsNullOrEmpty(pathPrepend) ? claudeDir : $"{claudeDir}:{pathPrepend}";
}
catch { }
Task.Run(() =>
{
try
{
if (client is ClaudeCliMcpConfigurator cliConfigurator)
{
cliConfigurator.ConfigureWithCapturedValues(
projectDir, claudePath, pathPrepend,
useHttpTransport, httpUrl,
uvxPath, gitUrl, packageName, shouldForceRefresh);
}
return (success: true, error: (string)null);
}
catch (Exception ex)
{
return (success: false, error: ex.Message);
}
}).ContinueWith(t =>
{
string errorMessage = null;
if (t.IsFaulted && t.Exception != null)
{
errorMessage = t.Exception.GetBaseException()?.Message ?? "Configuration failed";
}
else if (!t.Result.success)
{
errorMessage = t.Result.error;
}
EditorApplication.delayCall += () =>
{
statusRefreshInFlight.Remove(client);
lastStatusChecks.Remove(client);
if (errorMessage != null)
{
if (client is McpClientConfiguratorBase baseConfigurator)
{
baseConfigurator.Client.SetStatus(McpStatus.Error, errorMessage);
}
McpLog.Error($"Configuration failed: {errorMessage}");
RefreshClientStatus(client, forceImmediate: true);
}
else
{
// Registration succeeded - trust the status set by RegisterWithCapturedValues
// and update UI without re-verifying (which could fail due to CLI timing/scope issues)
lastStatusChecks[client] = DateTime.UtcNow;
ApplyStatusToUi(client);
}
UpdateManualConfiguration();
};
});
}
private void OnBrowseClaudeClicked()
{
string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX)
@ -396,7 +489,7 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
return (DateTime.UtcNow - last) > StatusRefreshInterval;
}
private void ApplyStatusToUi(IMcpClientConfigurator client, bool showChecking = false)
private void ApplyStatusToUi(IMcpClientConfigurator client, bool showChecking = false, string customMessage = null)
{
if (selectedClientIndex < 0 || selectedClientIndex >= configurators.Count)
return;
@ -410,7 +503,7 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
if (showChecking)
{
clientStatusLabel.text = "Checking...";
clientStatusLabel.text = customMessage ?? "Checking...";
clientStatusLabel.style.color = StyleKeyword.Null;
clientStatusIndicator.AddToClassList("warning");
configureButton.text = client.GetConfigureActionLabel();

View File

@ -674,6 +674,12 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
{
if (bridgeService.IsRunning)
{
// Clear any resume flags when user manually ends the session to prevent
// getting stuck in "Resuming..." state (the flag may have been set by a
// domain reload that started just before the user clicked End Session)
try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload); } catch { }
try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeHttpAfterReload); } catch { }
await bridgeService.StopAsync();
}
else
@ -717,6 +723,11 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
{
connectionToggleInProgress = true;
connectionToggleButton?.SetEnabled(false);
// Clear resume flags to prevent getting stuck in "Resuming..." state
try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload); } catch { }
try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeHttpAfterReload); } catch { }
await MCPServiceLocator.Bridge.StopAsync();
}
catch (Exception ex)

View File

@ -232,6 +232,11 @@ namespace MCPForUnity.Editor.Windows
toolsSection.Refresh();
}
// Throttle OnEditorUpdate to avoid per-frame overhead (GitHub issue #577).
// Connection status polling every frame caused expensive network checks 60+ times/sec.
private double _lastEditorUpdateTime;
private const double EditorUpdateIntervalSeconds = 2.0;
private void OnEnable()
{
EditorApplication.update += OnEditorUpdate;
@ -257,6 +262,16 @@ namespace MCPForUnity.Editor.Windows
private void OnEditorUpdate()
{
// Throttle to 2-second intervals instead of every frame.
// This prevents the expensive IsLocalHttpServerReachable() socket checks from running
// 60+ times per second, which caused main thread blocking and GC pressure.
double now = EditorApplication.timeSinceStartup;
if (now - _lastEditorUpdateTime < EditorUpdateIntervalSeconds)
{
return;
}
_lastEditorUpdateTime = now;
if (rootVisualElement == null || rootVisualElement.childCount == 0)
return;

View File

@ -56,6 +56,15 @@ mcp-for-unity = "main:main"
requires = ["setuptools>=64.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-dir]
"" = "src"
[tool.setuptools]
py-modules = ["main"]
[tool.coverage.run]
source = ["src"]
omit = [

View File

@ -37,6 +37,19 @@ except Exception:
from fastmcp import FastMCP
from logging.handlers import RotatingFileHandler
class WindowsSafeRotatingFileHandler(RotatingFileHandler):
"""RotatingFileHandler that gracefully handles Windows file locking during rotation."""
def doRollover(self):
"""Override to catch PermissionError on Windows when log file is locked."""
try:
super().doRollover()
except PermissionError:
# On Windows, another process may have the log file open.
# Skip rotation this time - we'll try again on the next rollover.
pass
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import WebSocketRoute
@ -69,7 +82,7 @@ try:
"~/Library/Application Support/UnityMCP"), "Logs")
os.makedirs(_log_dir, exist_ok=True)
_file_path = os.path.join(_log_dir, "unity_mcp_server.log")
_fh = RotatingFileHandler(
_fh = WindowsSafeRotatingFileHandler(
_file_path, maxBytes=512*1024, backupCount=2, encoding="utf-8")
_fh.setFormatter(logging.Formatter(config.log_format))
_fh.setLevel(getattr(logging, config.log_level))

View File

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import logging
import time
from typing import Annotated, Any, Literal
@ -15,6 +16,8 @@ from transport.legacy.unity_connection import async_send_command_with_retry, _ex
from services.state.external_changes_scanner import external_changes_scanner
import services.resources.editor_state as editor_state
logger = logging.getLogger(__name__)
@mcp_for_unity_tool(
description="Request a Unity asset database refresh and optionally a script compilation. Can optionally wait for readiness.",
@ -43,36 +46,65 @@ async def refresh_unity(
}
recovered_from_disconnect = False
# Don't retry on reload - refresh_unity triggers compilation/reload,
# so retrying would cause multiple reloads (issue #577)
response = await unity_transport.send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"refresh_unity",
params,
retry_on_reload=False,
)
# Option A: treat disconnects / retry hints as recoverable when wait_for_ready is true.
# Unity can legitimately disconnect during refresh/compile/domain reload, so callers should not
# interpret that as a hard failure (#503-style loops).
if isinstance(response, dict) and not response.get("success", True):
hint = response.get("hint")
err = (response.get("error") or response.get("message") or "").lower()
reason = _extract_response_reason(response)
is_retryable = (
hint == "retry"
# Handle connection errors during refresh/compile gracefully.
# Unity disconnects during domain reload, which is expected behavior - not a failure.
# If we sent the command and connection closed, the refresh was likely triggered successfully.
# Convert MCPResponse to dict if needed
response_dict = response if isinstance(response, dict) else (response.model_dump() if hasattr(response, "model_dump") else response.__dict__)
if not response_dict.get("success", True):
hint = response_dict.get("hint")
err = (response_dict.get("error") or response_dict.get("message") or "").lower()
reason = _extract_response_reason(response_dict)
# Connection closed/timeout during compile = refresh was triggered, Unity is reloading
# This is SUCCESS, not failure - don't return error to prevent Claude Code from retrying
is_connection_lost = (
"connection closed" in err
or "disconnected" in err
or "could not connect" in err # Connection failed during domain reload
or "aborted" in err # WinError 10053: connection aborted
or "timeout" in err
or reason == "reloading"
)
if (not wait_for_ready) or (not is_retryable):
return MCPResponse(**response)
if reason not in {"reloading", "no_unity_session"}:
if is_connection_lost and compile == "request":
# EXPECTED BEHAVIOR: When compile="request", Unity triggers domain reload which
# causes connection to close mid-command. This is NOT a failure - the refresh
# was successfully triggered. Treating this as success prevents Claude Code from
# retrying unnecessarily (which would cause multiple domain reloads - issue #577).
# The subsequent wait_for_ready loop (below) will verify Unity becomes ready.
logger.info("refresh_unity: Connection lost during compile (expected - domain reload triggered)")
recovered_from_disconnect = True
elif hint == "retry" or "could not connect" in err:
# Retryable error - proceed to wait loop if wait_for_ready
if not wait_for_ready:
return MCPResponse(**response_dict)
recovered_from_disconnect = True
else:
# Non-recoverable error - connection issue unrelated to domain reload
logger.warning(f"refresh_unity: Non-recoverable error (compile={compile}): {err[:100]}")
return MCPResponse(**response_dict)
# Optional server-side wait loop (defensive): if Unity tool doesn't wait or returns quickly,
# poll the canonical editor_state resource until ready or timeout.
ready_confirmed = False
if wait_for_ready:
timeout_s = 60.0
start = time.monotonic()
# Blocking reasons that indicate Unity is actually busy (not just stale status)
# Must match activityPhase values from EditorStateCache.cs
real_blocking_reasons = {"compiling", "domain_reload", "running_tests", "asset_import"}
while time.monotonic() - start < timeout_s:
state_resp = await editor_state.get_editor_state(ctx)
state = state_resp.model_dump() if hasattr(
@ -81,10 +113,28 @@ async def refresh_unity(
state, dict) else None
advice = (data or {}).get(
"advice") if isinstance(data, dict) else None
if isinstance(advice, dict) and advice.get("ready_for_tools") is True:
if isinstance(advice, dict):
# Exit if ready_for_tools is True
if advice.get("ready_for_tools") is True:
ready_confirmed = True
break
# Also exit if the only blocking reason is "stale_status" (Unity in background)
# Staleness means we can't confirm status, not that Unity is actually busy
blocking = set(advice.get("blocking_reasons") or [])
if not (blocking & real_blocking_reasons):
ready_confirmed = True # No real blocking reasons, consider ready
break
await asyncio.sleep(0.25)
# If we timed out without confirming readiness, log and return failure
if not ready_confirmed:
logger.warning(f"refresh_unity: Timed out after {timeout_s}s waiting for editor to become ready")
return MCPResponse(
success=False,
message=f"Refresh triggered but timed out after {timeout_s}s waiting for editor readiness.",
data={"timeout": True, "wait_seconds": timeout_s},
)
# After readiness is restored, clear any external-dirty flag for this instance so future tools can proceed cleanly.
try:
inst = unity_instance or await editor_state.infer_single_instance_id(ctx)
@ -100,4 +150,4 @@ async def refresh_unity(
data={"recovered_from_disconnect": True},
)
return MCPResponse(**response) if isinstance(response, dict) else response
return MCPResponse(**response_dict) if isinstance(response, dict) else response

View File

@ -233,14 +233,20 @@ class UnityConnection:
logger.error(f"Error during receive: {str(e)}")
raise
def send_command(self, command_type: str, params: dict[str, Any] = None) -> dict[str, Any]:
"""Send a command with retry/backoff and port rediscovery. Pings only when requested."""
def send_command(self, command_type: str, params: dict[str, Any] = None, max_attempts: int | None = None) -> dict[str, Any]:
"""Send a command with retry/backoff and port rediscovery. Pings only when requested.
Args:
command_type: The Unity command to send
params: Command parameters
max_attempts: Maximum retry attempts (None = use config default, 0 = no retries)
"""
# Defensive guard: catch empty/placeholder invocations early
if not command_type:
raise ValueError("MCP call missing command_type")
if params is None:
return MCPResponse(success=False, error="MCP call received with no parameters (client placeholder?)")
attempts = max(config.max_retries, 5)
attempts = max(config.max_retries, 5) if max_attempts is None else max_attempts
base_backoff = max(0.5, config.retry_delay)
def read_status_file(target_hash: str | None = None) -> dict | None:
@ -734,7 +740,8 @@ def send_command_with_retry(
*,
instance_id: str | None = None,
max_retries: int | None = None,
retry_ms: int | None = None
retry_ms: int | None = None,
retry_on_reload: bool = True
) -> dict[str, Any] | MCPResponse:
"""Send a command to a Unity instance, waiting politely through Unity reloads.
@ -744,6 +751,8 @@ def send_command_with_retry(
instance_id: Optional Unity instance identifier (name, hash, name@hash, etc.)
max_retries: Maximum number of retries for reload states
retry_ms: Delay between retries in milliseconds
retry_on_reload: If False, don't retry when Unity is reloading (for commands
that trigger compilation/reload and shouldn't be re-sent)
Returns:
Response dictionary or MCPResponse from Unity
@ -768,11 +777,15 @@ def send_command_with_retry(
# Clamp to [0, 30] to prevent misconfiguration from causing excessive waits
max_wait_s = max(0.0, min(max_wait_s, 30.0))
response = conn.send_command(command_type, params)
# If retry_on_reload=False, disable connection-level retries too (issue #577)
# Commands that trigger compilation/reload shouldn't retry on disconnect
send_max_attempts = None if retry_on_reload else 0
response = conn.send_command(command_type, params, max_attempts=send_max_attempts)
retries = 0
wait_started = None
reason = _extract_response_reason(response)
while _is_reloading_response(response) and retries < max_retries:
while retry_on_reload and _is_reloading_response(response) and retries < max_retries:
if wait_started is None:
wait_started = time.monotonic()
logger.debug(
@ -842,7 +855,8 @@ async def async_send_command_with_retry(
instance_id: str | None = None,
loop=None,
max_retries: int | None = None,
retry_ms: int | None = None
retry_ms: int | None = None,
retry_on_reload: bool = True
) -> dict[str, Any] | MCPResponse:
"""Async wrapper that runs the blocking retry helper in a thread pool.
@ -853,6 +867,7 @@ async def async_send_command_with_retry(
loop: Optional asyncio event loop
max_retries: Maximum number of retries for reload states
retry_ms: Delay between retries in milliseconds
retry_on_reload: If False, don't retry when Unity is reloading
Returns:
Response dictionary or MCPResponse on error
@ -864,7 +879,8 @@ async def async_send_command_with_retry(
return await loop.run_in_executor(
None,
lambda: send_command_with_retry(
command_type, params, instance_id=instance_id, max_retries=max_retries, retry_ms=retry_ms),
command_type, params, instance_id=instance_id, max_retries=max_retries,
retry_ms=retry_ms, retry_on_reload=retry_on_reload),
)
except Exception as e:
return MCPResponse(success=False, error=str(e))