Skip to content

Commit 270463c

Browse files
authored
Merge pull request #1320 from CoplayDev/fix/stdio-configurable-command-timeout
fix: make stdio bridge command timeout configurable (default 5m)
2 parents 9f84072 + 7997788 commit 270463c

3 files changed

Lines changed: 85 additions & 8 deletions

File tree

MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,33 @@ public static class StdioBridgeHost
5858
private static int currentUnityPort = 6400;
5959
private static bool isAutoConnectMode = false;
6060
private const ulong MaxFrameBytes = 64UL * 1024 * 1024;
61-
private const int FrameIOTimeoutMs = 30000;
61+
// Command/frame I/O timeout for the stdio bridge TCP hop. Previously a
62+
// hardcoded 30s const, which cut off long-running tool calls mid-execution
63+
// (the client would then reconnect and re-send, causing the bridge to
64+
// restart on a new port). Now defaults to 5 minutes and is overridable via
65+
// the UNITY_MCP_STDIO_COMMAND_TIMEOUT_MS environment variable.
66+
private const int DefaultFrameIOTimeoutMs = 300000;
67+
private static readonly int FrameIOTimeoutMs = ResolveFrameIOTimeoutMs();
6268
private static readonly Stopwatch _uptime = Stopwatch.StartNew();
6369
private static volatile int _consecutiveTimeouts = 0;
6470
private static bool _processCommandsHooked = false;
6571

72+
private static int ResolveFrameIOTimeoutMs()
73+
{
74+
try
75+
{
76+
string raw = Environment.GetEnvironmentVariable("UNITY_MCP_STDIO_COMMAND_TIMEOUT_MS");
77+
if (!string.IsNullOrWhiteSpace(raw)
78+
&& int.TryParse(raw.Trim(), out int ms)
79+
&& ms > 0)
80+
{
81+
return ms;
82+
}
83+
}
84+
catch { /* fall through to default */ }
85+
return DefaultFrameIOTimeoutMs;
86+
}
87+
6688
private static void IoInfo(string s) { McpLog.Info(s, always: false); }
6789

6890
private static bool IsDebugEnabled()
@@ -465,7 +487,9 @@ private static async Task ListenerLoopAsync(CancellationToken token)
465487
true
466488
);
467489

468-
client.ReceiveTimeout = 60000;
490+
// Keep the socket receive timeout at least as long as the command
491+
// timeout so it never fires before a long-running tool call completes.
492+
client.ReceiveTimeout = Math.Max(60000, FrameIOTimeoutMs);
469493

470494
_ = Task.Run(() => HandleClientAsync(client, token), token);
471495
}
@@ -825,7 +849,7 @@ private static void ProcessCommands()
825849

826850
// Evict commands stuck with IsExecuting=true for too long (e.g. from pre-reload state).
827851
long nowMs = _uptime.ElapsedMilliseconds;
828-
const long staleThresholdMs = 2L * FrameIOTimeoutMs; // 60s
852+
long staleThresholdMs = 2L * FrameIOTimeoutMs; // 2x the command timeout
829853
List<string> staleIds = null;
830854
foreach (var kvp in commandQueue)
831855
{

Server/src/core/config.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,27 @@
33
This file contains all configurable parameters for the server.
44
"""
55

6-
from dataclasses import dataclass
6+
import math
7+
import os
8+
from dataclasses import dataclass, field
9+
10+
11+
def _env_float(name: str, default: float) -> float:
12+
"""Read a positive, finite float from an environment variable.
13+
14+
Invalid, non-positive, or non-finite values (e.g. "inf", "1e309", "nan")
15+
are ignored so a bad override can't disable the timeout or produce unusable
16+
socket/timeout behaviour.
17+
"""
18+
raw = os.environ.get(name)
19+
if raw:
20+
try:
21+
value = float(raw.strip())
22+
if math.isfinite(value) and value > 0:
23+
return value
24+
except (TypeError, ValueError):
25+
pass
26+
return default
727

828

929
@dataclass
@@ -31,9 +51,19 @@ class ServerConfig:
3151
api_key_service_token: str | None = None # The token value
3252

3353
# Connection settings
34-
connection_timeout: float = 30.0
54+
# Socket receive timeout for a single Unity command (seconds). Raised from the
55+
# historical 30s so long-running tools (e.g. imports, test runs, batched edits)
56+
# aren't cut off mid-execution, which previously forced a reconnect + re-send and
57+
# made the Unity stdio bridge restart. Override via UNITY_MCP_CONNECTION_TIMEOUT.
58+
connection_timeout: float = field(
59+
default_factory=lambda: _env_float("UNITY_MCP_CONNECTION_TIMEOUT", 300.0)
60+
)
3561
# Hard ceiling on a command's total time across all retries (wedged-socket guard).
36-
command_total_timeout: float = 90.0
62+
# Kept above connection_timeout so a single slow-but-progressing command still fits.
63+
# Override via UNITY_MCP_COMMAND_TOTAL_TIMEOUT.
64+
command_total_timeout: float = field(
65+
default_factory=lambda: _env_float("UNITY_MCP_COMMAND_TOTAL_TIMEOUT", 600.0)
66+
)
3767
buffer_size: int = 16 * 1024 * 1024 # 16MB buffer
3868

3969
# STDIO framing behaviour

Server/tests/test_core_infrastructure_characterization.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -654,21 +654,44 @@ def error_tool():
654654
class TestServerConfigDefaults:
655655
"""Tests for ServerConfig default values."""
656656

657-
def test_config_default_values(self):
657+
def test_config_default_values(self, monkeypatch):
658658
"""Verify ServerConfig has expected default values."""
659+
# Clear env overrides so the defaults aren't masked by the ambient env.
660+
monkeypatch.delenv("UNITY_MCP_CONNECTION_TIMEOUT", raising=False)
661+
monkeypatch.delenv("UNITY_MCP_COMMAND_TOTAL_TIMEOUT", raising=False)
659662
config = ServerConfig()
660663

661664
assert config.unity_host == "127.0.0.1"
662665
assert config.unity_port == 6400
663666
assert config.mcp_port == 6500
664-
assert config.connection_timeout == 30.0
667+
assert config.connection_timeout == 300.0
668+
assert config.command_total_timeout == 600.0
665669
assert config.buffer_size == 16 * 1024 * 1024
666670
assert config.require_framing is True
667671
assert config.handshake_timeout == 1.0
668672
assert config.framed_receive_timeout == 2.0
669673
assert config.max_heartbeat_frames == 16
670674
assert config.heartbeat_timeout == 2.0
671675

676+
def test_timeout_env_overrides_are_honored(self, monkeypatch):
677+
"""Valid env overrides for the stdio timeouts are applied."""
678+
monkeypatch.setenv("UNITY_MCP_CONNECTION_TIMEOUT", "120.5")
679+
monkeypatch.setenv("UNITY_MCP_COMMAND_TOTAL_TIMEOUT", "240")
680+
config = ServerConfig()
681+
682+
assert config.connection_timeout == 120.5
683+
assert config.command_total_timeout == 240.0
684+
685+
@pytest.mark.parametrize("bad_value", ["0", "-5", "abc", "", "inf", "Infinity", "1e309", "nan"])
686+
def test_timeout_env_invalid_values_fall_back(self, monkeypatch, bad_value):
687+
"""Invalid, non-positive, or non-finite overrides preserve the defaults."""
688+
monkeypatch.setenv("UNITY_MCP_CONNECTION_TIMEOUT", bad_value)
689+
monkeypatch.setenv("UNITY_MCP_COMMAND_TOTAL_TIMEOUT", bad_value)
690+
config = ServerConfig()
691+
692+
assert config.connection_timeout == 300.0
693+
assert config.command_total_timeout == 600.0
694+
672695
def test_config_logging_defaults(self):
673696
"""Verify logging configuration defaults."""
674697
config = ServerConfig()

0 commit comments

Comments
 (0)