Skip to content

Commit dfc3485

Browse files
authored
Merge pull request #1021 from Emerix/feature/configurable-init-timeout
feat: Add configurable init_timeout for PlayMode test initialization
2 parents 2a6be79 + 233e88e commit dfc3485

8 files changed

Lines changed: 259 additions & 7 deletions

File tree

MCPForUnity/Editor/Services/TestJobManager.cs

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ internal sealed class TestJob
4040
public List<TestJobFailure> FailuresSoFar { get; set; }
4141
public string Error { get; set; }
4242
public TestRunResult Result { get; set; }
43+
public long InitTimeoutMs { get; set; }
4344
}
4445

4546
/// <summary>
@@ -50,7 +51,8 @@ internal static class TestJobManager
5051
// Keep this small to avoid ballooning payloads during polling.
5152
private const int FailureCap = 25;
5253
private const long StuckThresholdMs = 60_000;
53-
private const long InitializationTimeoutMs = 15_000; // 15 seconds to call OnRunStarted, else fail
54+
private const long DefaultInitializationTimeoutMs = 15_000; // 15 seconds default; override per-job via run_tests init_timeout param
55+
private const long MaxInitializationTimeoutMs = 600_000; // 10 minutes hard cap
5456
private const int MaxJobsToKeep = 10;
5557
private const long MinPersistIntervalMs = 1000; // Throttle persistence to reduce overhead
5658

@@ -139,6 +141,7 @@ private sealed class PersistedJob
139141
public long? last_finished_unix_ms { get; set; }
140142
public List<TestJobFailure> failures_so_far { get; set; }
141143
public string error { get; set; }
144+
public long init_timeout_ms { get; set; }
142145
}
143146

144147
private static TestJobStatus ParseStatus(string status)
@@ -201,6 +204,7 @@ private static void TryRestoreFromSessionState()
201204
LastFinishedUnixMs = pj.last_finished_unix_ms,
202205
FailuresSoFar = pj.failures_so_far ?? new List<TestJobFailure>(),
203206
Error = pj.error,
207+
InitTimeoutMs = pj.init_timeout_ms,
204208
// Intentionally not persisted to avoid ballooning SessionState.
205209
Result = null
206210
};
@@ -273,7 +277,8 @@ private static void PersistToSessionState(bool force = false)
273277
last_finished_test_full_name = j.LastFinishedTestFullName,
274278
last_finished_unix_ms = j.LastFinishedUnixMs,
275279
failures_so_far = (j.FailuresSoFar ?? new List<TestJobFailure>()).Take(FailureCap).ToList(),
276-
error = j.Error
280+
error = j.Error,
281+
init_timeout_ms = j.InitTimeoutMs
277282
})
278283
.ToList();
279284

@@ -294,8 +299,12 @@ private static void PersistToSessionState(bool force = false)
294299
}
295300
}
296301

297-
public static string StartJob(TestMode mode, TestFilterOptions filterOptions = null)
302+
public static string StartJob(TestMode mode, TestFilterOptions filterOptions = null, long initTimeoutMs = 0)
298303
{
304+
// Clamp to valid range: non-positive values mean "use default", cap at 10 minutes
305+
if (initTimeoutMs < 0) initTimeoutMs = 0;
306+
if (initTimeoutMs > MaxInitializationTimeoutMs) initTimeoutMs = MaxInitializationTimeoutMs;
307+
299308
string jobId = Guid.NewGuid().ToString("N");
300309
long started = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
301310
string modeStr = mode.ToString();
@@ -316,7 +325,8 @@ public static string StartJob(TestMode mode, TestFilterOptions filterOptions = n
316325
LastFinishedUnixMs = null,
317326
FailuresSoFar = new List<TestJobFailure>(),
318327
Error = null,
319-
Result = null
328+
Result = null,
329+
InitTimeoutMs = initTimeoutMs
320330
};
321331

322332
// Single lock scope for check-and-set to avoid TOCTOU race
@@ -491,9 +501,10 @@ internal static TestJob GetJob(string jobId)
491501
if (job.Status == TestJobStatus.Running && job.TotalTests == null)
492502
{
493503
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
494-
if (!EditorApplication.isCompiling && !EditorApplication.isUpdating && now - job.StartedUnixMs > InitializationTimeoutMs)
504+
long initTimeout = job.InitTimeoutMs > 0 ? job.InitTimeoutMs : DefaultInitializationTimeoutMs;
505+
if (!EditorApplication.isCompiling && !EditorApplication.isUpdating && now - job.StartedUnixMs > initTimeout)
495506
{
496-
McpLog.Warn($"[TestJobManager] Job {jobId} failed to initialize within {InitializationTimeoutMs}ms, auto-failing");
507+
McpLog.Warn($"[TestJobManager] Job {jobId} failed to initialize within {initTimeout}ms, auto-failing");
497508
job.Status = TestJobStatus.Failed;
498509
job.Error = "Test job failed to initialize (tests did not start within timeout)";
499510
job.FinishedUnixMs = now;

MCPForUnity/Editor/Tools/RunTests.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ public static Task<object> HandleCommand(JObject @params)
4545
bool includeFailedTests = p.GetBool("includeFailedTests");
4646

4747
var filterOptions = GetFilterOptions(@params);
48-
string jobId = TestJobManager.StartJob(parsedMode.Value, filterOptions);
48+
long initTimeoutMs = p.GetInt("initTimeout") ?? 0;
49+
string jobId = TestJobManager.StartJob(parsedMode.Value, filterOptions, initTimeoutMs);
4950

5051
return Task.FromResult<object>(new SuccessResponse("Test job started.", new
5152
{

Server/src/services/tools/run_tests.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,13 @@ async def run_tests(
167167
"Include details for failed/skipped tests only (default: false)"] = False,
168168
include_details: Annotated[bool,
169169
"Include details for all tests (default: false)"] = False,
170+
init_timeout: Annotated[int | None,
171+
"Initialization timeout in milliseconds. PlayMode tests may need longer "
172+
"due to domain reload (default: 15000). Recommended: 120000 for PlayMode."] = None,
170173
) -> RunTestsStartResponse | MCPResponse:
174+
if init_timeout is not None and init_timeout <= 0:
175+
return MCPResponse(success=False, error="init_timeout must be a positive integer (milliseconds) or None")
176+
171177
unity_instance = await get_unity_instance_from_context(ctx)
172178

173179
gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True)
@@ -197,6 +203,8 @@ def _coerce_string_list(value) -> list[str] | None:
197203
params["includeFailedTests"] = True
198204
if include_details:
199205
params["includeDetails"] = True
206+
if init_timeout is not None and init_timeout > 0:
207+
params["initTimeout"] = init_timeout
200208

201209
response = await unity_transport.send_with_unity_instance(
202210
async_send_command_with_retry,

Server/tests/integration/test_run_tests_async.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,66 @@ async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, p
3333
assert resp.data.job_id == "abc123"
3434

3535

36+
@pytest.mark.asyncio
37+
async def test_run_tests_forwards_init_timeout(monkeypatch):
38+
from services.tools.run_tests import run_tests
39+
40+
captured = {}
41+
42+
async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
43+
captured["params"] = params
44+
return {"success": True, "data": {"job_id": "abc123", "status": "running", "mode": "PlayMode"}}
45+
46+
import services.tools.run_tests as mod
47+
monkeypatch.setattr(
48+
mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance)
49+
50+
resp = await run_tests(
51+
DummyContext(),
52+
mode="PlayMode",
53+
init_timeout=120000,
54+
)
55+
assert captured["params"]["initTimeout"] == 120000
56+
assert resp.success is True
57+
58+
59+
@pytest.mark.asyncio
60+
async def test_run_tests_omits_init_timeout_when_none(monkeypatch):
61+
from services.tools.run_tests import run_tests
62+
63+
captured = {}
64+
65+
async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
66+
captured["params"] = params
67+
return {"success": True, "data": {"job_id": "abc123", "status": "running", "mode": "EditMode"}}
68+
69+
import services.tools.run_tests as mod
70+
monkeypatch.setattr(
71+
mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance)
72+
73+
resp = await run_tests(DummyContext(), mode="EditMode")
74+
assert "initTimeout" not in captured["params"]
75+
assert resp.success is True
76+
77+
78+
@pytest.mark.asyncio
79+
async def test_run_tests_rejects_negative_init_timeout():
80+
from services.tools.run_tests import run_tests
81+
82+
resp = await run_tests(DummyContext(), mode="EditMode", init_timeout=-1)
83+
assert resp.success is False
84+
assert "init_timeout" in resp.error
85+
86+
87+
@pytest.mark.asyncio
88+
async def test_run_tests_rejects_zero_init_timeout():
89+
from services.tools.run_tests import run_tests
90+
91+
resp = await run_tests(DummyContext(), mode="EditMode", init_timeout=0)
92+
assert resp.success is False
93+
assert "init_timeout" in resp.error
94+
95+
3696
@pytest.mark.asyncio
3797
async def test_get_test_job_forwards_job_id(monkeypatch):
3898
from services.tools.run_tests import get_test_job
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Reflection;
4+
using NUnit.Framework;
5+
using MCPForUnity.Editor.Services;
6+
7+
namespace MCPForUnityTests.Editor.Services
8+
{
9+
/// <summary>
10+
/// Tests for TestJobManager's per-job InitTimeoutMs feature.
11+
/// Uses reflection to manipulate internal state since StartJob triggers a real test run.
12+
/// </summary>
13+
public class TestJobManagerInitTimeoutTests
14+
{
15+
private FieldInfo _jobsField;
16+
private FieldInfo _currentJobIdField;
17+
private MethodInfo _getJobMethod;
18+
private MethodInfo _persistMethod;
19+
private MethodInfo _restoreMethod;
20+
private Type _testJobType;
21+
22+
private string _originalJobId;
23+
24+
[SetUp]
25+
public void SetUp()
26+
{
27+
var asm = typeof(MCPServiceLocator).Assembly;
28+
var managerType = asm.GetType("MCPForUnity.Editor.Services.TestJobManager");
29+
Assert.NotNull(managerType, "Could not find TestJobManager");
30+
31+
_testJobType = asm.GetType("MCPForUnity.Editor.Services.TestJob");
32+
Assert.NotNull(_testJobType, "Could not find TestJob");
33+
34+
_jobsField = managerType.GetField("Jobs", BindingFlags.NonPublic | BindingFlags.Static);
35+
Assert.NotNull(_jobsField, "Could not find Jobs field");
36+
37+
_currentJobIdField = managerType.GetField("_currentJobId", BindingFlags.NonPublic | BindingFlags.Static);
38+
Assert.NotNull(_currentJobIdField, "Could not find _currentJobId field");
39+
40+
_getJobMethod = managerType.GetMethod("GetJob", BindingFlags.NonPublic | BindingFlags.Static);
41+
Assert.NotNull(_getJobMethod, "Could not find GetJob method");
42+
43+
_persistMethod = managerType.GetMethod("PersistToSessionState", BindingFlags.NonPublic | BindingFlags.Static);
44+
Assert.NotNull(_persistMethod, "Could not find PersistToSessionState method");
45+
46+
_restoreMethod = managerType.GetMethod("TryRestoreFromSessionState", BindingFlags.NonPublic | BindingFlags.Static);
47+
Assert.NotNull(_restoreMethod, "Could not find TryRestoreFromSessionState method");
48+
49+
// Snapshot original state
50+
_originalJobId = _currentJobIdField.GetValue(null) as string;
51+
// We'll restore _currentJobId in TearDown; Jobs dictionary is shared static state
52+
}
53+
54+
[TearDown]
55+
public void TearDown()
56+
{
57+
// Restore original state
58+
_currentJobIdField.SetValue(null, _originalJobId);
59+
// Clean up any test jobs we inserted
60+
var jobs = _jobsField.GetValue(null) as System.Collections.IDictionary;
61+
jobs?.Remove("test-init-timeout-job");
62+
jobs?.Remove("test-init-timeout-default");
63+
jobs?.Remove("test-init-timeout-persist");
64+
// Flush cleaned state to SessionState so synthetic jobs don't survive domain reloads.
65+
// The persist test writes to SessionState; without this, the stub job would be
66+
// restored on the next [InitializeOnLoadMethod] and pollute later test runs.
67+
_persistMethod.Invoke(null, new object[] { true });
68+
}
69+
70+
[Test]
71+
public void GetJob_WithCustomInitTimeout_UsesPerJobTimeout()
72+
{
73+
// Arrange: insert a job with a custom init timeout and a start time far enough in the
74+
// past to exceed the default 15s but within the custom 120s.
75+
var jobs = _jobsField.GetValue(null) as System.Collections.IDictionary;
76+
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
77+
78+
var job = Activator.CreateInstance(_testJobType);
79+
_testJobType.GetProperty("JobId").SetValue(job, "test-init-timeout-job");
80+
_testJobType.GetProperty("Status").SetValue(job, TestJobStatus.Running);
81+
_testJobType.GetProperty("Mode").SetValue(job, "PlayMode");
82+
_testJobType.GetProperty("StartedUnixMs").SetValue(job, now - 30_000); // 30s ago
83+
_testJobType.GetProperty("LastUpdateUnixMs").SetValue(job, now - 30_000);
84+
_testJobType.GetProperty("TotalTests").SetValue(job, null); // Not initialized yet
85+
_testJobType.GetProperty("InitTimeoutMs").SetValue(job, 120_000L); // 120s custom timeout
86+
_testJobType.GetProperty("FailuresSoFar").SetValue(job, new List<TestJobFailure>());
87+
88+
jobs["test-init-timeout-job"] = job;
89+
_currentJobIdField.SetValue(null, "test-init-timeout-job");
90+
91+
// Act: GetJob should NOT auto-fail because 30s < 120s custom timeout
92+
var result = _getJobMethod.Invoke(null, new object[] { "test-init-timeout-job" });
93+
94+
// Assert: job should still be running
95+
var status = (TestJobStatus)_testJobType.GetProperty("Status").GetValue(result);
96+
Assert.AreEqual(TestJobStatus.Running, status,
97+
"Job with 120s custom timeout should not auto-fail after 30s");
98+
}
99+
100+
[Test]
101+
public void GetJob_WithDefaultTimeout_AutoFailsAfter15Seconds()
102+
{
103+
// Arrange: insert a job with InitTimeoutMs=0 (use default) and start time 20s ago
104+
var jobs = _jobsField.GetValue(null) as System.Collections.IDictionary;
105+
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
106+
107+
var job = Activator.CreateInstance(_testJobType);
108+
_testJobType.GetProperty("JobId").SetValue(job, "test-init-timeout-default");
109+
_testJobType.GetProperty("Status").SetValue(job, TestJobStatus.Running);
110+
_testJobType.GetProperty("Mode").SetValue(job, "EditMode");
111+
_testJobType.GetProperty("StartedUnixMs").SetValue(job, now - 20_000); // 20s ago
112+
_testJobType.GetProperty("LastUpdateUnixMs").SetValue(job, now - 20_000);
113+
_testJobType.GetProperty("TotalTests").SetValue(job, null);
114+
_testJobType.GetProperty("InitTimeoutMs").SetValue(job, 0L); // Use default
115+
_testJobType.GetProperty("FailuresSoFar").SetValue(job, new List<TestJobFailure>());
116+
117+
jobs["test-init-timeout-default"] = job;
118+
_currentJobIdField.SetValue(null, "test-init-timeout-default");
119+
120+
// Act: GetJob should auto-fail because 20s > 15s default
121+
var result = _getJobMethod.Invoke(null, new object[] { "test-init-timeout-default" });
122+
123+
// Assert: job should be failed
124+
var status = (TestJobStatus)_testJobType.GetProperty("Status").GetValue(result);
125+
Assert.AreEqual(TestJobStatus.Failed, status,
126+
"Job with default timeout should auto-fail after 20s");
127+
}
128+
129+
[Test]
130+
public void InitTimeoutMs_SurvivesPersistAndRestore()
131+
{
132+
// Arrange: insert a job with custom InitTimeoutMs
133+
var jobs = _jobsField.GetValue(null) as System.Collections.IDictionary;
134+
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
135+
136+
var job = Activator.CreateInstance(_testJobType);
137+
_testJobType.GetProperty("JobId").SetValue(job, "test-init-timeout-persist");
138+
_testJobType.GetProperty("Status").SetValue(job, TestJobStatus.Running);
139+
_testJobType.GetProperty("Mode").SetValue(job, "PlayMode");
140+
_testJobType.GetProperty("StartedUnixMs").SetValue(job, now);
141+
_testJobType.GetProperty("LastUpdateUnixMs").SetValue(job, now);
142+
_testJobType.GetProperty("TotalTests").SetValue(job, null);
143+
_testJobType.GetProperty("InitTimeoutMs").SetValue(job, 90_000L);
144+
_testJobType.GetProperty("FailuresSoFar").SetValue(job, new List<TestJobFailure>());
145+
146+
jobs["test-init-timeout-persist"] = job;
147+
_currentJobIdField.SetValue(null, "test-init-timeout-persist");
148+
149+
// Act: persist then restore (simulates domain reload)
150+
_persistMethod.Invoke(null, new object[] { true });
151+
// Clear in-memory state
152+
jobs.Remove("test-init-timeout-persist");
153+
_currentJobIdField.SetValue(null, null);
154+
// Restore from SessionState
155+
_restoreMethod.Invoke(null, null);
156+
157+
// Assert: restored job should have the same InitTimeoutMs
158+
var restoredJobs = _jobsField.GetValue(null) as System.Collections.IDictionary;
159+
Assert.IsTrue(restoredJobs.Contains("test-init-timeout-persist"),
160+
"Job should be restored from SessionState");
161+
162+
var restoredJob = restoredJobs["test-init-timeout-persist"];
163+
var restoredTimeout = (long)_testJobType.GetProperty("InitTimeoutMs").GetValue(restoredJob);
164+
Assert.AreEqual(90_000L, restoredTimeout,
165+
"InitTimeoutMs should survive persist/restore cycle");
166+
}
167+
}
168+
}

TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TestJobManagerInitTimeoutTests.cs.meta

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/development/README-DEV-zh.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ uv run python -m cli.main editor tests --failed-only
126126

127127
```
128128
run_tests(mode="EditMode")
129+
run_tests(mode="PlayMode", init_timeout=120000) # PlayMode 由于域重载可能需要更长的初始化时间
129130
get_test_job(job_id="<id>", wait_timeout=60)
130131
```
131132

docs/development/README-DEV.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ uv run python -m cli.main editor tests --failed-only
126126

127127
```
128128
run_tests(mode="EditMode")
129+
run_tests(mode="PlayMode", init_timeout=120000) # PlayMode may need longer init due to domain reload
129130
get_test_job(job_id="<id>", wait_timeout=60)
130131
```
131132

0 commit comments

Comments
 (0)