Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions MCPForUnity/Editor/Services/TestJobManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,26 @@ private static void TryRestoreFromSessionState()
{
_currentJobId = null;
}

// Detect and clean up stale "running" jobs that were orphaned by domain reload.
// After a domain reload, TestRunStatus resets to not-running, but _currentJobId
// may still be set. If the job hasn't been updated recently, it's likely orphaned.
if (!string.IsNullOrEmpty(_currentJobId) && Jobs.TryGetValue(_currentJobId, out var currentJob))
{
if (currentJob.Status == TestJobStatus.Running)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
long staleCutoffMs = 5 * 60 * 1000; // 5 minutes
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Extract the stale job cutoff into a named constant or configuration to avoid hard-coded timing logic.

Using the inline 5 * 60 * 1000 magic number makes this timeout harder to manage and align with other components. Consider defining a static readonly constant (e.g., DefaultStaleJobTimeoutMs) or reading it from config so the stale job policy is centralized and easier to adjust.

if (now - currentJob.LastUpdateUnixMs > staleCutoffMs)
{
McpLog.Warn($"[TestJobManager] Clearing stale job {_currentJobId} (last update {(now - currentJob.LastUpdateUnixMs) / 1000}s ago)");
currentJob.Status = TestJobStatus.Failed;
currentJob.Error = "Job orphaned after domain reload";
currentJob.FinishedUnixMs = now;
_currentJobId = null;
}
}
}
}
}
catch (Exception ex)
Expand Down
11 changes: 11 additions & 0 deletions MCPForUnity/Editor/Services/TestRunnerNoThrottle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,17 @@ static TestRunnerNoThrottle()

#endregion

/// <summary>
/// Apply no-throttling preemptively before tests start.
/// Call this before Execute() for PlayMode tests to ensure Unity isn't throttled
/// during the Play mode transition (before RunStarted fires).
/// </summary>
public static void ApplyNoThrottlingPreemptive()
{
SetTestRunActive(true);
ApplyNoThrottling();
}

private static void ApplyNoThrottling()
{
if (!AreSettingsCaptured())
Expand Down
30 changes: 23 additions & 7 deletions MCPForUnity/Editor/Services/TestRunnerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ public async Task<TestRunResult> RunTestsAsync(TestMode mode, TestFilterOptions
// (Issue #525: EditMode tests were blocked by save dialog)
SaveDirtyScenesIfNeeded();

// Apply no-throttling preemptively for PlayMode tests. This ensures Unity
// isn't throttled during the Play mode transition (which requires multiple
// editor frames). Without this, unfocused Unity may never reach RunStarted
// where throttling would normally be disabled.
if (mode == TestMode.PlayMode)
{
TestRunnerNoThrottle.ApplyNoThrottlingPreemptive();
}

_testRunnerApi.Execute(settings);

runTask = _runCompletionSource.Task;
Expand Down Expand Up @@ -184,17 +193,24 @@ public void RunStarted(ITestAdaptor testsToRun)

public void RunFinished(ITestResultAdaptor result)
{
if (_runCompletionSource == null)
{
return;
}

// Always create payload and clean up job state, even if _runCompletionSource is null.
// This handles domain reload scenarios (e.g., PlayMode tests) where the TestRunnerService
// is recreated and _runCompletionSource is lost, but TestJobManager state persists via
// SessionState and the Test Runner still delivers the RunFinished callback.
var payload = TestRunResult.Create(result, _leafResults);
_runCompletionSource.TrySetResult(payload);
_runCompletionSource = null;

// Clean up state regardless of _runCompletionSource - these methods safely handle
// the case where no MCP job exists (e.g., manual test runs via Unity UI).
TestRunStatus.MarkFinished();
TestJobManager.OnRunFinished();
TestJobManager.FinalizeCurrentJobFromRunFinished(payload);

// Report result to awaiting caller if we have a completion source
if (_runCompletionSource != null)
{
_runCompletionSource.TrySetResult(payload);
_runCompletionSource = null;
}
}

public void TestStarted(ITestAdaptor test)
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,8 +439,13 @@ Your privacy matters to us. All telemetry is optional and designed to respect yo

## Troubleshooting ❓

<details>
<summary><strong>Click to view common issues and fixes...</strong></summary>
<details>
<summary><strong>Click to view common issues and fixes...</strong></summary>

- **Focus Permission Request (macOS/Windows/Linux):**
- When running PlayMode tests with Unity in the background, MCP for Unity may temporarily switch focus to Unity to prevent OS-level throttling from stalling tests.
- On **macOS**, you may be prompted to grant accessibility/automation permissions for your terminal or IDE to control window focus.
- This is normal behavior to ensure tests complete reliably when Unity is not the active window.

- **Unity Bridge Not Running/Connecting:**
- Ensure Unity Editor is open.
Expand Down
9 changes: 6 additions & 3 deletions Server/src/services/tools/refresh_unity.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,13 @@ async def refresh_unity(
# 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 "")
err = (response.get("error") or response.get("message") or "").lower()
reason = _extract_response_reason(response)
is_retryable = (hint == "retry") or (
"disconnected" in str(err).lower())
is_retryable = (
hint == "retry"
or "disconnected" in err
or "could not connect" in err # Connection failed during domain reload
)
if (not wait_for_ready) or (not is_retryable):
return MCPResponse(**response)
if reason not in {"reloading", "no_unity_session"}:
Expand Down
37 changes: 31 additions & 6 deletions Server/src/services/tools/run_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from __future__ import annotations

import asyncio
import logging
import time
from typing import Annotated, Any, Literal

from fastmcp import Context
Expand All @@ -14,6 +16,9 @@
from services.tools.preflight import preflight
import transport.unity_transport as unity_transport
from transport.legacy.unity_connection import async_send_command_with_retry
from utils.focus_nudge import nudge_unity_focus, should_nudge

logger = logging.getLogger(__name__)


class RunTestsSummary(BaseModel):
Expand Down Expand Up @@ -195,28 +200,48 @@ async def _fetch_status() -> dict[str, Any]:
if wait_timeout and wait_timeout > 0:
deadline = asyncio.get_event_loop().time() + wait_timeout
poll_interval = 2.0 # Poll Unity every 2 seconds

while True:
response = await _fetch_status()

if not isinstance(response, dict):
return MCPResponse(success=False, error=str(response))

if not response.get("success", True):
return MCPResponse(**response)

# Check if tests are done
data = response.get("data", {})
status = data.get("status", "")
if status in ("succeeded", "failed", "cancelled"):
return GetTestJobResponse(**response)


# Check if Unity needs a focus nudge to make progress
# This handles OS-level throttling (e.g., macOS App Nap) that can
# stall PlayMode tests when Unity is in the background.
progress = data.get("progress", {})
editor_is_focused = progress.get("editor_is_focused", True)
last_update_unix_ms = data.get("last_update_unix_ms")
current_time_ms = int(time.time() * 1000)

if should_nudge(
status=status,
editor_is_focused=editor_is_focused,
last_update_unix_ms=last_update_unix_ms,
current_time_ms=current_time_ms,
stall_threshold_ms=10_000, # 10 seconds without progress
):
logger.info(f"Test job {job_id} appears stalled (unfocused Unity), attempting nudge...")
nudged = await nudge_unity_focus(focus_duration_s=0.5)
if nudged:
logger.info(f"Test job {job_id} nudge completed")

# Check timeout
remaining = deadline - asyncio.get_event_loop().time()
if remaining <= 0:
# Timeout reached, return current status
return GetTestJobResponse(**response)

# Wait before next poll (but don't exceed remaining time)
await asyncio.sleep(min(poll_interval, remaining))

Expand Down
Loading