Fix PlayMode tests stalling when unfocused (python refresh utility), improve domain reload recovery and refresh tool (#554)

* Fix test job state management after domain reload

- TestRunnerService.RunFinished: Always clean up job state even when
  _runCompletionSource is null (happens after PlayMode domain reload)
- TestJobManager: Detect and clear stale jobs (5+ min without updates)
  on startup to recover from stuck state after domain reload
- refresh_unity.py: Add "could not connect" to retryable errors when
  wait_for_ready=True, so connection failures during domain reload
  trigger waiting instead of immediate failure

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

* Add focus nudge to handle OS-level throttling during PlayMode tests

When Unity is unfocused, macOS App Nap (and similar OS features) can
throttle the process, causing PlayMode tests to stall even with Unity
No Throttling mode enabled.

Changes:
- Add ApplyNoThrottlingPreemptive() to TestRunnerNoThrottle for early
  throttle prevention before PlayMode Execute()
- Add focus_nudge.py utility that temporarily focuses Unity and returns
  focus to the original app (supports macOS, Windows, Linux)
- Integrate focus nudge into get_test_job polling - when tests appear
  stalled (unfocused + no progress for 10s), automatically nudge Unity

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

* Fix code review issues in focus_nudge.py

- Remove redundant time import (already imported at module level)
- Escape window titles in PowerShell script to prevent injection
- Remove unused Callable import

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

* Improve focus nudge logging and fix skipped tests

- Improve logging in focus_nudge.py: rate limit skip and focus return at INFO level
- Improve logging in run_tests.py: show nudge completion status
- Fix path resolution in test_logging_stdout.py and test_transport_framing.py
- Add PlayMode tests to UnityMCPTests project for testing PlayMode runner

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

* Add troubleshooting note about focus permission requests

When running PlayMode tests with Unity in the background, the focus
nudge feature may trigger OS permission prompts (especially on macOS
for accessibility/automation). Document this expected behavior.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
main
dsarno 2026-01-14 15:02:40 -08:00 committed by GitHub
parent b874922cb0
commit 39bff4ae27
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 556 additions and 22 deletions

View File

@ -178,6 +178,26 @@ namespace MCPForUnity.Editor.Services
{
_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
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)

View File

@ -73,6 +73,17 @@ namespace MCPForUnity.Editor.Services
#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())

View File

@ -110,6 +110,15 @@ namespace MCPForUnity.Editor.Services
// (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;
@ -184,17 +193,24 @@ namespace MCPForUnity.Editor.Services
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)

View File

@ -442,6 +442,11 @@ Your privacy matters to us. All telemetry is optional and designed to respect yo
<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.
- Check the status window: Window > MCP for Unity.

View File

@ -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"}:

View File

@ -2,6 +2,8 @@
from __future__ import annotations
import asyncio
import logging
import time
from typing import Annotated, Any, Literal
from fastmcp import Context
@ -14,6 +16,9 @@ from services.tools import get_unity_instance_from_context
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):
@ -211,6 +216,26 @@ async def get_test_job(
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:

View File

@ -0,0 +1,321 @@
"""
Focus nudge utility for handling OS-level throttling of background Unity.
When Unity is unfocused, the OS (especially macOS App Nap) can heavily throttle
the process, causing PlayMode tests to stall. This utility temporarily brings
Unity to focus, allows it to process, then returns focus to the original app.
"""
from __future__ import annotations
import asyncio
import logging
import platform
import shutil
import subprocess
import time
logger = logging.getLogger(__name__)
# Minimum seconds between nudges to avoid focus thrashing
_MIN_NUDGE_INTERVAL_S = 5.0
_last_nudge_time: float = 0.0
def _is_available() -> bool:
"""Check if focus nudging is available on this platform."""
system = platform.system()
if system == "Darwin":
return shutil.which("osascript") is not None
elif system == "Windows":
# PowerShell is typically available on Windows
return shutil.which("powershell") is not None
elif system == "Linux":
return shutil.which("xdotool") is not None
return False
def _get_frontmost_app_macos() -> str | None:
"""Get the name of the frontmost application on macOS."""
try:
result = subprocess.run(
[
"osascript", "-e",
'tell application "System Events" to get name of first process whose frontmost is true'
],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
return result.stdout.strip()
except Exception as e:
logger.debug(f"Failed to get frontmost app: {e}")
return None
def _focus_app_macos(app_name: str) -> bool:
"""Focus an application by name on macOS."""
try:
result = subprocess.run(
["osascript", "-e", f'tell application "{app_name}" to activate'],
capture_output=True,
text=True,
timeout=5,
)
return result.returncode == 0
except Exception as e:
logger.debug(f"Failed to focus app {app_name}: {e}")
return False
def _get_frontmost_app_windows() -> str | None:
"""Get the title of the frontmost window on Windows."""
try:
# PowerShell command to get active window title
script = '''
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class Win32 {
[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder text, int count);
}
"@
$hwnd = [Win32]::GetForegroundWindow()
$sb = New-Object System.Text.StringBuilder 256
[Win32]::GetWindowText($hwnd, $sb, 256)
$sb.ToString()
'''
result = subprocess.run(
["powershell", "-Command", script],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
return result.stdout.strip()
except Exception as e:
logger.debug(f"Failed to get frontmost window: {e}")
return None
def _focus_app_windows(window_title: str) -> bool:
"""Focus a window by title on Windows. For Unity, uses Unity Editor pattern."""
try:
# For Unity, we use a pattern match since the title varies
if window_title == "Unity":
script = '''
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class Win32 {
[DllImport("user32.dll")]
public static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")]
public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
}
"@
$unity = Get-Process | Where-Object {$_.MainWindowTitle -like "*Unity*"} | Select-Object -First 1
if ($unity) {
[Win32]::ShowWindow($unity.MainWindowHandle, 9)
[Win32]::SetForegroundWindow($unity.MainWindowHandle)
}
'''
else:
# Try to find window by title - escape special PowerShell characters
safe_title = window_title.replace("'", "''").replace("`", "``")
script = f'''
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class Win32 {{
[DllImport("user32.dll")]
public static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")]
public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
}}
"@
$proc = Get-Process | Where-Object {{$_.MainWindowTitle -eq '{safe_title}'}} | Select-Object -First 1
if ($proc) {{
[Win32]::ShowWindow($proc.MainWindowHandle, 9)
[Win32]::SetForegroundWindow($proc.MainWindowHandle)
}}
'''
result = subprocess.run(
["powershell", "-Command", script],
capture_output=True,
text=True,
timeout=5,
)
return result.returncode == 0
except Exception as e:
logger.debug(f"Failed to focus window {window_title}: {e}")
return False
def _get_frontmost_app_linux() -> str | None:
"""Get the window ID of the frontmost window on Linux."""
try:
result = subprocess.run(
["xdotool", "getactivewindow"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
return result.stdout.strip()
except Exception as e:
logger.debug(f"Failed to get active window: {e}")
return None
def _focus_app_linux(window_id: str) -> bool:
"""Focus a window by ID on Linux, or Unity by name."""
try:
if window_id == "Unity":
# Find Unity window by name pattern
result = subprocess.run(
["xdotool", "search", "--name", "Unity"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
window_id = result.stdout.strip().split("\n")[0]
else:
return False
result = subprocess.run(
["xdotool", "windowactivate", window_id],
capture_output=True,
text=True,
timeout=5,
)
return result.returncode == 0
except Exception as e:
logger.debug(f"Failed to focus window {window_id}: {e}")
return False
def _get_frontmost_app() -> str | None:
"""Get the frontmost application/window (platform-specific)."""
system = platform.system()
if system == "Darwin":
return _get_frontmost_app_macos()
elif system == "Windows":
return _get_frontmost_app_windows()
elif system == "Linux":
return _get_frontmost_app_linux()
return None
def _focus_app(app_or_window: str) -> bool:
"""Focus an application/window (platform-specific)."""
system = platform.system()
if system == "Darwin":
return _focus_app_macos(app_or_window)
elif system == "Windows":
return _focus_app_windows(app_or_window)
elif system == "Linux":
return _focus_app_linux(app_or_window)
return False
async def nudge_unity_focus(
focus_duration_s: float = 0.5,
force: bool = False,
) -> bool:
"""
Temporarily focus Unity to allow it to process, then return focus.
Args:
focus_duration_s: How long to keep Unity focused (seconds)
force: If True, ignore the minimum interval between nudges
Returns:
True if nudge was performed, False if skipped or failed
"""
global _last_nudge_time
if not _is_available():
logger.debug("Focus nudging not available on this platform")
return False
# Rate limit nudges
now = time.monotonic()
if not force and (now - _last_nudge_time) < _MIN_NUDGE_INTERVAL_S:
logger.info("Skipping nudge - too soon since last nudge")
return False
# Get current frontmost app
original_app = _get_frontmost_app()
if original_app is None:
logger.debug("Could not determine frontmost app")
return False
# Check if Unity is already focused (no nudge needed)
if "Unity" in original_app:
logger.debug("Unity already focused, no nudge needed")
return False
logger.info(f"Nudging Unity focus (will return to {original_app})")
_last_nudge_time = now
# Focus Unity
if not _focus_app("Unity"):
logger.warning("Failed to focus Unity")
return False
# Wait for Unity to process
await asyncio.sleep(focus_duration_s)
# Return focus to original app
if original_app and original_app != "Unity":
if _focus_app(original_app):
logger.info(f"Returned focus to {original_app}")
else:
logger.warning(f"Failed to return focus to {original_app}")
return True
def should_nudge(
status: str,
editor_is_focused: bool,
last_update_unix_ms: int | None,
current_time_ms: int | None = None,
stall_threshold_ms: int = 10_000,
) -> bool:
"""
Determine if we should nudge Unity based on test job state.
Args:
status: Job status ("running", "succeeded", "failed")
editor_is_focused: Whether Unity reports being focused
last_update_unix_ms: Last time the job was updated (Unix ms)
current_time_ms: Current time (Unix ms), or None to use current time
stall_threshold_ms: How long without updates before considering it stalled
Returns:
True if conditions suggest a nudge would help
"""
# Only nudge running jobs
if status != "running":
return False
# Only nudge unfocused Unity
if editor_is_focused:
return False
# Check if job appears stalled
if last_update_unix_ms is None:
return True # No updates yet, might be stuck at start
if current_time_ms is None:
current_time_ms = int(time.time() * 1000)
time_since_update_ms = current_time_ms - last_update_unix_ms
return time_since_update_ms > stall_threshold_ms

View File

@ -5,9 +5,9 @@ import pytest
# locate server src dynamically to avoid hardcoded layout assumptions
ROOT = Path(__file__).resolve().parents[1]
ROOT = Path(__file__).resolve().parents[2] # tests/integration -> tests -> Server
candidates = [
ROOT / "Server",
ROOT / "src",
]
SRC = next((p for p in candidates if p.exists()), None)
if SRC is None:

View File

@ -11,9 +11,9 @@ from pathlib import Path
import pytest
# locate server src dynamically to avoid hardcoded layout assumptions
ROOT = Path(__file__).resolve().parents[1]
ROOT = Path(__file__).resolve().parents[2] # tests/integration -> tests -> Server
candidates = [
ROOT / "Server",
ROOT / "src",
]
SRC = next((p for p in candidates if p.exists()), None)
if SRC is None:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7d8f92bb5476145f7b4a14a3ff0181c6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,21 @@
{
"name": "MCPForUnityTests.PlayMode",
"rootNamespace": "",
"references": [
"UnityEngine.TestRunner",
"UnityEditor.TestRunner"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"nunit.framework.dll"
],
"autoReferenced": false,
"defineConstraints": [
"UNITY_INCLUDE_TESTS"
],
"versionDefines": [],
"noEngineReferences": false
}

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: ee22713734c3444ea97b26bc4f4009c6
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,86 @@
using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
namespace MCPForUnityTests.PlayMode
{
/// <summary>
/// Basic PlayMode tests to verify the MCP test runner handles PlayMode correctly.
/// These tests exercise coroutine-based testing which requires Play mode.
/// </summary>
public class PlayModeBasicTests
{
[UnityTest]
public IEnumerator GameObjectCreation_InPlayMode_Succeeds()
{
var go = new GameObject("TestObject");
Assert.IsNotNull(go);
Assert.AreEqual("TestObject", go.name);
yield return null; // Wait one frame
Assert.IsTrue(go != null); // Still exists after frame
Object.Destroy(go);
}
[UnityTest]
public IEnumerator WaitForSeconds_CompletesAfterDelay()
{
float startTime = Time.time;
yield return new WaitForSeconds(0.1f);
float elapsed = Time.time - startTime;
Assert.GreaterOrEqual(elapsed, 0.09f, "Should have waited at least 0.09 seconds");
}
[UnityTest]
public IEnumerator MultipleFrames_ProgressCorrectly()
{
int frameCount = Time.frameCount;
yield return null;
yield return null;
yield return null;
int newFrameCount = Time.frameCount;
Assert.Greater(newFrameCount, frameCount, "Frame count should have advanced");
}
[UnityTest]
public IEnumerator Component_AddAndRemove_InPlayMode()
{
var go = new GameObject("ComponentTest");
yield return null;
var rb = go.AddComponent<Rigidbody>();
Assert.IsNotNull(rb);
Assert.IsTrue(go.GetComponent<Rigidbody>() != null);
yield return null;
Object.Destroy(rb);
yield return null;
Assert.IsTrue(go.GetComponent<Rigidbody>() == null);
Object.Destroy(go);
}
[UnityTest]
public IEnumerator Coroutine_CanYieldMultipleTimes()
{
int counter = 0;
for (int i = 0; i < 5; i++)
{
counter++;
yield return null;
}
Assert.AreEqual(5, counter);
}
}
}

View File

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