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.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Models;
namespace MCPForUnity.Editor.Clients.Configurators 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 public ClaudeCodeConfigurator() : base(new McpClient
{ {
name = "Claude Code", 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, 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> public override IList<string> GetInstallationSteps() => new List<string>
{ {
"Open your project in Claude Code", "Ensure Claude CLI is installed (comes with Claude Code)",
"Click Configure in MCP for Unity (or manually edit ~/.claude.json)", "Click Register to add UnityMCP via 'claude mcp add'",
"The MCP server will be added to the global mcpServers section", "The server will be automatically available in Claude Code",
"Restart Claude Code to apply changes" "Use Unregister to remove via 'claude mcp remove'"
}; };
} }
} }

View File

@ -352,8 +352,11 @@ namespace MCPForUnity.Editor.Clients
/// Internal thread-safe version of CheckStatus. /// Internal thread-safe version of CheckStatus.
/// Can be called from background threads because all main-thread-only values are passed as parameters. /// 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. /// 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> /// </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 try
{ {
@ -391,7 +394,7 @@ namespace MCPForUnity.Editor.Clients
} }
catch { } 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 (ExecPath.TryRun(claudePath, "mcp list", projectDir, out var listStdout, out var listStderr, 10000, pathPrepend))
{ {
if (!string.IsNullOrEmpty(listStdout) && listStdout.IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0) if (!string.IsNullOrEmpty(listStdout) && listStdout.IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0)
@ -401,7 +404,11 @@ namespace MCPForUnity.Editor.Clients
bool currentUseHttp = useHttpTransport; bool currentUseHttp = useHttpTransport;
// Get detailed info about the registration to check transport type // 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 // Parse the output to determine registered transport mode
// The CLI output format contains "Type: http" or "Type: stdio" // The CLI output format contains "Type: http" or "Type: stdio"
@ -409,14 +416,60 @@ namespace MCPForUnity.Editor.Clients
bool registeredWithStdio = getStdout.Contains("Type: stdio", StringComparison.OrdinalIgnoreCase); bool registeredWithStdio = getStdout.Contains("Type: stdio", StringComparison.OrdinalIgnoreCase);
// Check for transport mismatch // 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"; expectedPackageSource = AssetPathUtility.GetMcpServerPackageSource();
string currentTransport = currentUseHttp ? "HTTP" : "stdio"; configuredPackageSource = ExtractPackageSourceFromCliOutput(getStdout);
string errorMsg = $"Transport mismatch: Claude Code is registered with {registeredTransport} but current setting is {currentTransport}. Click Configure to re-register."; hasVersionMismatch = !string.IsNullOrEmpty(configuredPackageSource) &&
client.SetStatus(McpStatus.Error, errorMsg); !string.Equals(configuredPackageSource, expectedPackageSource, StringComparison.OrdinalIgnoreCase);
McpLog.Warn(errorMsg); }
return client.status;
// 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;
}
} }
} }
@ -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() private void Register()
{ {
var pathService = MCPServiceLocator.Paths; var pathService = MCPServiceLocator.Paths;
@ -468,6 +600,7 @@ namespace MCPForUnity.Editor.Clients
{ {
var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts();
// Use central helper that checks both DevModeForceServerRefresh AND local path detection. // 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 devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty;
args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}--from \"{gitUrl}\" {packageName}"; args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}--from \"{gitUrl}\" {packageName}";
} }
@ -496,17 +629,10 @@ namespace MCPForUnity.Editor.Clients
} }
catch { } catch { }
// Check if UnityMCP already exists and remove it first to ensure clean registration // Remove any existing registrations - handle both "UnityMCP" and "unityMCP" (legacy)
// This ensures we always use the current transport mode setting McpLog.Info("Removing any existing UnityMCP registrations before adding...");
bool serverExists = ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out _, out _, 7000, pathPrepend); ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out _, out _, 7000, pathPrepend);
if (serverExists) ExecPath.TryRun(claudePath, "mcp remove unityMCP", projectDir, out _, out _, 7000, pathPrepend);
{
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...");
}
}
// Now add the registration with the current transport mode // Now add the registration with the current transport mode
if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) 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"; 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...");
if (!serverExists) ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out _, out _, 7000, pathPrepend);
{ ExecPath.TryRun(claudePath, "mcp remove unityMCP", projectDir, out _, out _, 7000, pathPrepend);
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}");
}
McpLog.Info("MCP server successfully unregistered from Claude Code.");
client.SetStatus(McpStatus.NotConfigured); client.SetStatus(McpStatus.NotConfigured);
// Status is already set - no need for blocking CheckStatus() call
} }
public override string GetManualSnippet() public override string GetManualSnippet()
@ -577,7 +690,7 @@ namespace MCPForUnity.Editor.Clients
"# Unregister the MCP server:\n" + "# Unregister the MCP server:\n" +
"claude mcp remove UnityMCP\n\n" + "claude mcp remove UnityMCP\n\n" +
"# List registered servers:\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)) if (string.IsNullOrEmpty(uvxPath))
@ -587,6 +700,7 @@ namespace MCPForUnity.Editor.Clients
string packageSource = AssetPathUtility.GetMcpServerPackageSource(); string packageSource = AssetPathUtility.GetMcpServerPackageSource();
// Use central helper that checks both DevModeForceServerRefresh AND local path detection. // 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 devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty;
return "# Register the MCP server with Claude Code:\n" + return "# Register the MCP server with Claude Code:\n" +
@ -594,7 +708,7 @@ namespace MCPForUnity.Editor.Clients
"# Unregister the MCP server:\n" + "# Unregister the MCP server:\n" +
"claude mcp remove UnityMCP\n\n" + "claude mcp remove UnityMCP\n\n" +
"# List registered servers:\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> 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)", "Use Register to add UnityMCP (or run claude mcp add UnityMCP)",
"Restart Claude Code" "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. /// Determines whether uvx should use --no-cache --refresh flags.
/// Returns true if DevModeForceServerRefresh is enabled OR if the server URL is a local path. /// 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. /// 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> /// </summary>
public static bool ShouldForceUvxRefresh() public static bool ShouldForceUvxRefresh()
{ {

View File

@ -21,6 +21,7 @@ namespace MCPForUnity.Editor.Helpers
{ {
if (args == null) return; if (args == null) return;
// Use central helper that checks both DevModeForceServerRefresh AND local path detection. // 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; if (!AssetPathUtility.ShouldForceUvxRefresh()) return;
args.Add(new TomlString { Value = "--no-cache" }); args.Add(new TomlString { Value = "--no-cache" });
args.Add(new TomlString { Value = "--refresh" }); 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) private static IList<string> BuildUvxArgs(string fromUrl, string packageName)
{ {
// Dev mode: force a fresh install/resolution (avoids stale cached builds while iterating). // 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. // Keep ordering consistent with other uvx builders: dev flags first, then --from <url>, then package name.
var args = new List<string>(); var args = new List<string>();

View File

@ -54,10 +54,24 @@ namespace MCPForUnity.Editor.Migrations
{ {
try try
{ {
if (!ConfigUsesStdIo(configurator.Client)) if (!configurator.SupportsAutoConfigure)
continue; 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; continue;
MCPServiceLocator.Client.ConfigureClient(configurator); MCPServiceLocator.Client.ConfigureClient(configurator);

View File

@ -30,7 +30,17 @@ namespace MCPForUnity.Editor.Services
private static long? _domainReloadAfterUnixMs; private static long? _domainReloadAfterUnixMs;
private static double _lastUpdateTimeSinceStartup; 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; private static JObject _cached;
@ -263,16 +273,78 @@ namespace MCPForUnity.Editor.Services
{ {
// Throttle to reduce overhead while keeping the snapshot fresh enough for polling clients. // Throttle to reduce overhead while keeping the snapshot fresh enough for polling clients.
double now = EditorApplication.timeSinceStartup; double now = EditorApplication.timeSinceStartup;
if (now - _lastUpdateTimeSinceStartup < MinUpdateIntervalSeconds) // Use GetActualIsCompiling() to avoid Play mode false positives (issue #582)
bool isCompiling = GetActualIsCompiling();
// Check for compilation edge transitions (always update on these)
bool compilationEdge = isCompiling != _lastIsCompiling;
if (!compilationEdge && now - _lastUpdateTimeSinceStartup < MinUpdateIntervalSeconds)
{ {
// Still update on compilation edge transitions to keep timestamps meaningful. return;
bool isCompiling = GetActualIsCompiling();
if (isCompiling == _lastIsCompiling)
{
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; _lastUpdateTimeSinceStartup = now;
ForceUpdate("tick"); ForceUpdate("tick");
} }
@ -425,6 +497,10 @@ namespace MCPForUnity.Editor.Services
{ {
_cached = BuildSnapshot("rebuild"); _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(); 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. // 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 devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty;
string args = string.IsNullOrEmpty(fromUrl) string args = string.IsNullOrEmpty(fromUrl)
? $"{devFlags}{packageName} --transport http --http-url {httpUrl}" ? $"{devFlags}{packageName} --transport http --http-url {httpUrl}"

View File

@ -229,6 +229,12 @@ namespace MCPForUnity.Editor.Services.Transport
lock (PendingLock) 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); ready = new List<(string, PendingCommand)>(Pending.Count);
foreach (var kvp in Pending) foreach (var kvp in Pending)
{ {

View File

@ -821,6 +821,12 @@ namespace MCPForUnity.Editor.Services.Transport.Transports
List<(string id, QueuedCommand command)> work; List<(string id, QueuedCommand command)> work;
lock (lockObj) 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); work = new List<(string, QueuedCommand)>(commandQueue.Count);
foreach (var kvp in commandQueue) foreach (var kvp in commandQueue)
{ {

View File

@ -221,6 +221,13 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
var client = configurators[selectedClientIndex]; var client = configurators[selectedClientIndex];
// Handle CLI configurators asynchronously
if (client is ClaudeCliMcpConfigurator)
{
ConfigureClaudeCliAsync(client);
return;
}
try try
{ {
MCPServiceLocator.Client.ConfigureClient(client); 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() private void OnBrowseClaudeClicked()
{ {
string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX)
@ -396,7 +489,7 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
return (DateTime.UtcNow - last) > StatusRefreshInterval; 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) if (selectedClientIndex < 0 || selectedClientIndex >= configurators.Count)
return; return;
@ -410,7 +503,7 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
if (showChecking) if (showChecking)
{ {
clientStatusLabel.text = "Checking..."; clientStatusLabel.text = customMessage ?? "Checking...";
clientStatusLabel.style.color = StyleKeyword.Null; clientStatusLabel.style.color = StyleKeyword.Null;
clientStatusIndicator.AddToClassList("warning"); clientStatusIndicator.AddToClassList("warning");
configureButton.text = client.GetConfigureActionLabel(); configureButton.text = client.GetConfigureActionLabel();

View File

@ -674,6 +674,12 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
{ {
if (bridgeService.IsRunning) 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(); await bridgeService.StopAsync();
} }
else else
@ -717,6 +723,11 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
{ {
connectionToggleInProgress = true; connectionToggleInProgress = true;
connectionToggleButton?.SetEnabled(false); 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(); await MCPServiceLocator.Bridge.StopAsync();
} }
catch (Exception ex) catch (Exception ex)

View File

@ -232,6 +232,11 @@ namespace MCPForUnity.Editor.Windows
toolsSection.Refresh(); 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() private void OnEnable()
{ {
EditorApplication.update += OnEditorUpdate; EditorApplication.update += OnEditorUpdate;
@ -257,6 +262,16 @@ namespace MCPForUnity.Editor.Windows
private void OnEditorUpdate() 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) if (rootVisualElement == null || rootVisualElement.childCount == 0)
return; return;

View File

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

View File

@ -37,6 +37,19 @@ except Exception:
from fastmcp import FastMCP from fastmcp import FastMCP
from logging.handlers import RotatingFileHandler 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.requests import Request
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from starlette.routing import WebSocketRoute from starlette.routing import WebSocketRoute
@ -69,7 +82,7 @@ try:
"~/Library/Application Support/UnityMCP"), "Logs") "~/Library/Application Support/UnityMCP"), "Logs")
os.makedirs(_log_dir, exist_ok=True) os.makedirs(_log_dir, exist_ok=True)
_file_path = os.path.join(_log_dir, "unity_mcp_server.log") _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") _file_path, maxBytes=512*1024, backupCount=2, encoding="utf-8")
_fh.setFormatter(logging.Formatter(config.log_format)) _fh.setFormatter(logging.Formatter(config.log_format))
_fh.setLevel(getattr(logging, config.log_level)) _fh.setLevel(getattr(logging, config.log_level))

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging
import time import time
from typing import Annotated, Any, Literal 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 from services.state.external_changes_scanner import external_changes_scanner
import services.resources.editor_state as editor_state import services.resources.editor_state as editor_state
logger = logging.getLogger(__name__)
@mcp_for_unity_tool( @mcp_for_unity_tool(
description="Request a Unity asset database refresh and optionally a script compilation. Can optionally wait for readiness.", 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 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( response = await unity_transport.send_with_unity_instance(
async_send_command_with_retry, async_send_command_with_retry,
unity_instance, unity_instance,
"refresh_unity", "refresh_unity",
params, params,
retry_on_reload=False,
) )
# Option A: treat disconnects / retry hints as recoverable when wait_for_ready is true. # Handle connection errors during refresh/compile gracefully.
# Unity can legitimately disconnect during refresh/compile/domain reload, so callers should not # Unity disconnects during domain reload, which is expected behavior - not a failure.
# interpret that as a hard failure (#503-style loops). # If we sent the command and connection closed, the refresh was likely triggered successfully.
if isinstance(response, dict) and not response.get("success", True): # Convert MCPResponse to dict if needed
hint = response.get("hint") response_dict = response if isinstance(response, dict) else (response.model_dump() if hasattr(response, "model_dump") else response.__dict__)
err = (response.get("error") or response.get("message") or "").lower() if not response_dict.get("success", True):
reason = _extract_response_reason(response) hint = response_dict.get("hint")
is_retryable = ( err = (response_dict.get("error") or response_dict.get("message") or "").lower()
hint == "retry" 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 "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 is_connection_lost and compile == "request":
if reason not in {"reloading", "no_unity_session"}: # 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 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, # 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. # poll the canonical editor_state resource until ready or timeout.
ready_confirmed = False
if wait_for_ready: if wait_for_ready:
timeout_s = 60.0 timeout_s = 60.0
start = time.monotonic() 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: while time.monotonic() - start < timeout_s:
state_resp = await editor_state.get_editor_state(ctx) state_resp = await editor_state.get_editor_state(ctx)
state = state_resp.model_dump() if hasattr( state = state_resp.model_dump() if hasattr(
@ -81,10 +113,28 @@ async def refresh_unity(
state, dict) else None state, dict) else None
advice = (data or {}).get( advice = (data or {}).get(
"advice") if isinstance(data, dict) else None "advice") if isinstance(data, dict) else None
if isinstance(advice, dict) and advice.get("ready_for_tools") is True: if isinstance(advice, dict):
break # 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) 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. # After readiness is restored, clear any external-dirty flag for this instance so future tools can proceed cleanly.
try: try:
inst = unity_instance or await editor_state.infer_single_instance_id(ctx) 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}, 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)}") logger.error(f"Error during receive: {str(e)}")
raise raise
def send_command(self, command_type: str, params: dict[str, Any] = None) -> dict[str, Any]: 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.""" """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 # Defensive guard: catch empty/placeholder invocations early
if not command_type: if not command_type:
raise ValueError("MCP call missing command_type") raise ValueError("MCP call missing command_type")
if params is None: if params is None:
return MCPResponse(success=False, error="MCP call received with no parameters (client placeholder?)") 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) base_backoff = max(0.5, config.retry_delay)
def read_status_file(target_hash: str | None = None) -> dict | None: 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, instance_id: str | None = None,
max_retries: int | 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: ) -> dict[str, Any] | MCPResponse:
"""Send a command to a Unity instance, waiting politely through Unity reloads. """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.) instance_id: Optional Unity instance identifier (name, hash, name@hash, etc.)
max_retries: Maximum number of retries for reload states max_retries: Maximum number of retries for reload states
retry_ms: Delay between retries in milliseconds 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: Returns:
Response dictionary or MCPResponse from Unity 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 # Clamp to [0, 30] to prevent misconfiguration from causing excessive waits
max_wait_s = max(0.0, min(max_wait_s, 30.0)) 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 retries = 0
wait_started = None wait_started = None
reason = _extract_response_reason(response) 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: if wait_started is None:
wait_started = time.monotonic() wait_started = time.monotonic()
logger.debug( logger.debug(
@ -842,7 +855,8 @@ async def async_send_command_with_retry(
instance_id: str | None = None, instance_id: str | None = None,
loop=None, loop=None,
max_retries: int | 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: ) -> dict[str, Any] | MCPResponse:
"""Async wrapper that runs the blocking retry helper in a thread pool. """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 loop: Optional asyncio event loop
max_retries: Maximum number of retries for reload states max_retries: Maximum number of retries for reload states
retry_ms: Delay between retries in milliseconds retry_ms: Delay between retries in milliseconds
retry_on_reload: If False, don't retry when Unity is reloading
Returns: Returns:
Response dictionary or MCPResponse on error Response dictionary or MCPResponse on error
@ -864,7 +879,8 @@ async def async_send_command_with_retry(
return await loop.run_in_executor( return await loop.run_in_executor(
None, None,
lambda: send_command_with_retry( 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: except Exception as e:
return MCPResponse(success=False, error=str(e)) return MCPResponse(success=False, error=str(e))