Skip to content
Open
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
14 changes: 8 additions & 6 deletions MCPForUnity/Editor/Constants/EditorPrefKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ internal static class EditorPrefKeys
{
internal const string UseHttpTransport = "MCPForUnity.UseHttpTransport";
internal const string HttpTransportScope = "MCPForUnity.HttpTransportScope"; // "local" | "remote"
internal const string LastLocalHttpServerPid = "MCPForUnity.LocalHttpServer.LastPid";
internal const string LastLocalHttpServerPort = "MCPForUnity.LocalHttpServer.LastPort";
internal const string LastLocalHttpServerStartedUtc = "MCPForUnity.LocalHttpServer.LastStartedUtc";
internal const string LastLocalHttpServerPidArgsHash = "MCPForUnity.LocalHttpServer.LastPidArgsHash";
internal const string LastLocalHttpServerPidFilePath = "MCPForUnity.LocalHttpServer.LastPidFilePath";
internal const string LastLocalHttpServerInstanceToken = "MCPForUnity.LocalHttpServer.LastInstanceToken";
// Unity-managed local HTTP server state. EditorPrefs are shared by every editor the user runs,
// so PidFileManager appends ".{projectHash}.{port}" to each of these: two editors, or two ports,
// never read or clear each other's slot.
internal const string LocalHttpServerPid = "MCPForUnity.LocalHttpServer.Pid";
internal const string LocalHttpServerStartedUtc = "MCPForUnity.LocalHttpServer.StartedUtc";
internal const string LocalHttpServerPidArgsHash = "MCPForUnity.LocalHttpServer.PidArgsHash";
internal const string LocalHttpServerPidFilePath = "MCPForUnity.LocalHttpServer.PidFilePath";
internal const string LocalHttpServerInstanceToken = "MCPForUnity.LocalHttpServer.InstanceToken";
internal const string DebugLogs = "MCPForUnity.DebugLogs";
internal const string ValidationLevel = "MCPForUnity.ValidationLevel";
internal const string UnitySocketPort = "MCPForUnity.UnitySocketPort";
Expand Down
18 changes: 16 additions & 2 deletions MCPForUnity/Editor/Services/IServerManagementService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,25 @@ public interface IServerManagementService
bool StopLocalHttpServer();

/// <summary>
/// Stop the Unity-managed local HTTP server if a handshake/pidfile exists,
/// even if the current transport selection has changed.
/// Port of the local HTTP server this editor process launched, from the SessionState launch
/// marker. False for servers launched by another editor, by an earlier run of this editor,
/// or externally.
/// </summary>
bool TryGetLaunchedLocalHttpServerPort(out int port);

/// <summary>
/// Stop the local HTTP server this editor process launched, even if the current transport
/// selection has changed. No-op when this process did not launch one.
/// </summary>
bool StopManagedLocalHttpServer();

/// <summary>
/// Asks the server on <paramref name="port"/> how many Unity instances other than this
/// project are connected (GET /api/instances). Bounded by <paramref name="timeoutMs"/>;
/// returns false when the server did not answer with a well-formed response in time.
/// </summary>
bool TryCountOtherConnectedUnityInstances(int port, int timeoutMs, out int otherInstances);

/// <summary>
/// Best-effort detection: returns true if a local MCP HTTP server appears to be running
/// on the configured local URL/port (used to drive UI state even if the session is not active).
Expand Down
56 changes: 44 additions & 12 deletions MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,43 @@ namespace MCPForUnity.Editor.Services
/// <summary>
/// Best-effort cleanup when the Unity Editor is quitting.
/// - Stops active transports so clients don't see a "hung" session longer than necessary.
/// - Stops the local HTTP server this Unity instance launched (handshake/pidfile-based), so a
/// headless server doesn't become an invisible orphan. This runs on quit only, never on domain reload.
/// - Stops the local HTTP server this editor process launched, but only when no other Unity
/// instance is still connected to it (last one out turns the lights off). A headless server
/// has no terminal window, so an unstopped one would be an invisible orphan; a stopped one
/// that other editors still use disconnects all of them. This runs on quit only, never on
/// domain reload.
/// </summary>
[InitializeOnLoad]
internal static class McpEditorShutdownCleanup
{
// Upper bound for asking the server how many Unity instances are still connected. The quit
// handler already waits up to 750 ms on transport stops; keep the whole thing near a second.
internal const int InstanceProbeTimeoutMs = 500;

static McpEditorShutdownCleanup()
{
// Guard against duplicate subscriptions across domain reloads.
try { EditorApplication.quitting -= OnEditorQuitting; } catch { }
EditorApplication.quitting += OnEditorQuitting;
}

// A -batchmode/CI instance resolves the interactive editor's server via the global
// pidfile+port handshake, so cleanup there would stop another user's server. Mirror the
// sibling guards (HttpAutoStartHandler, StdioBridgeHost): skip in batch unless opted in.
// A -batchmode/CI instance never auto-starts the server (HttpAutoStartHandler has the same
// guard), so it has nothing of its own to stop. Mirror the sibling guards (HttpAutoStartHandler,
// StdioBridgeHost): skip in batch unless opted in.
internal static bool ShouldRunCleanup() =>
ShouldRunCleanup(Application.isBatchMode, Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"));

internal static bool ShouldRunCleanup(bool isBatchMode, string allowBatchEnv) =>
!isBatchMode || !string.IsNullOrWhiteSpace(allowBatchEnv);

/// <summary>
/// Last-one-out decision for a server this editor process launched. <paramref name="otherConnectedInstances"/>
/// is null when the server did not answer the instance probe in time; that fails toward leaving
/// it running, because killing a server other editors depend on is worse than a stray process.
/// </summary>
internal static bool ShouldStopManagedServer(int? otherConnectedInstances) =>
otherConnectedInstances == 0;

private static void OnEditorQuitting()
{
if (!ShouldRunCleanup()) return;
Expand All @@ -52,14 +67,32 @@ private static void OnEditorQuitting()
McpLog.Warn($"Shutdown cleanup: failed to stop transports: {ex.Message}");
}

// 2) Stop the local HTTP server this Unity instance launched (best-effort).
// Headless servers have no terminal window, so an unstopped one is an invisible orphan.
// StopManagedLocalHttpServer only stops the server matching our pidfile+instance-token handshake,
// so it never touches servers launched by other Unity instances. This runs on quit only;
// domain reloads must NOT stop the server (and don't — this handler is gated on EditorApplication.quitting).
// 2) Stop the local HTTP server this editor process launched (best-effort).
// The launch marker lives in SessionState, which is per editor process, so a server launched
// by another editor on this machine (or started externally) is never resolved here. Even for
// our own launch, other editors may share it: ask the server first and only stop it when we
// are the last Unity instance connected.
try
{
MCPServiceLocator.Server.StopManagedLocalHttpServer();
var server = MCPServiceLocator.Server;
if (!server.TryGetLaunchedLocalHttpServerPort(out int port))
{
return;
}

int? otherInstances = server.TryCountOtherConnectedUnityInstances(port, InstanceProbeTimeoutMs, out int count)
? count
: (int?)null;

if (!ShouldStopManagedServer(otherInstances))
{
McpLog.Debug(otherInstances.HasValue
? $"Shutdown cleanup: leaving local HTTP server on port {port} running; {otherInstances.Value} other Unity instance(s) still connected."
: $"Shutdown cleanup: leaving local HTTP server on port {port} running; it did not report connected instances within {InstanceProbeTimeoutMs} ms.");
return;
}

server.StopManagedLocalHttpServer();
}
catch (Exception ex)
{
Expand All @@ -68,4 +101,3 @@ private static void OnEditorQuitting()
}
}
}

46 changes: 28 additions & 18 deletions MCPForUnity/Editor/Services/Server/IPidFileManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Interface for managing PID files and handshake state for the local HTTP server.
/// Handles persistence of server process information across Unity domain reloads.
/// Handshake and tracking state is keyed per project and per port, and the launch
/// marker is per editor process, so concurrent editors sharing one server (or running
/// servers on different ports) never act on each other's state.
/// </summary>
public interface IPidFileManager
{
Expand All @@ -27,37 +29,42 @@ public interface IPidFileManager
/// <returns>True if a valid PID was read</returns>
bool TryReadPid(string pidFilePath, out int pid);

/// <summary>
/// Attempts to extract the port number from a PID file path.
/// </summary>
/// <param name="pidFilePath">Path to the PID file</param>
/// <param name="port">Output: the port number</param>
/// <returns>True if the port was extracted successfully</returns>
bool TryGetPortFromPidFilePath(string pidFilePath, out int port);

/// <summary>
/// Deletes a PID file.
/// </summary>
/// <param name="pidFilePath">Path to the PID file to delete</param>
void DeletePidFile(string pidFilePath);

/// <summary>
/// Stores the handshake information (PID file path and instance token) in EditorPrefs.
/// Stores the handshake for a server this editor process just launched: the PID file path and
/// instance token go to EditorPrefs (per project, per port; survives editor restarts so the
/// server can still be stopped deterministically later), and the port is recorded in
/// SessionState as the launch marker (survives domain reloads, dies with this editor process).
/// </summary>
/// <param name="port">Port the server was launched on</param>
/// <param name="pidFilePath">Path to the PID file</param>
/// <param name="instanceToken">Unique instance token for the server</param>
void StoreHandshake(string pidFilePath, string instanceToken);
void StoreHandshake(int port, string pidFilePath, string instanceToken);

/// <summary>
/// Attempts to retrieve stored handshake information from EditorPrefs.
/// Attempts to retrieve the stored handshake for a port (this project's slot only).
/// </summary>
/// <param name="port">Port to look up</param>
/// <param name="pidFilePath">Output: stored PID file path</param>
/// <param name="instanceToken">Output: stored instance token</param>
/// <returns>True if valid handshake information was found</returns>
bool TryGetHandshake(out string pidFilePath, out string instanceToken);
bool TryGetHandshake(int port, out string pidFilePath, out string instanceToken);

/// <summary>
/// Stores PID tracking information in EditorPrefs.
/// Returns the port of the server this editor process launched, if any. False for servers
/// launched by other editors, by an earlier run of this editor, or externally.
/// </summary>
/// <param name="port">Output: the launched port</param>
/// <returns>True if this editor process launched a server</returns>
bool TryGetLaunchedPort(out int port);

/// <summary>
/// Stores PID tracking information in EditorPrefs (per project, per port).
/// </summary>
/// <param name="pid">The process ID</param>
/// <param name="port">The port number</param>
Expand All @@ -74,15 +81,18 @@ public interface IPidFileManager
bool TryGetStoredPid(int expectedPort, out int pid);

/// <summary>
/// Gets the stored args hash for the tracked server.
/// Gets the stored args hash for the tracked server on a port.
/// </summary>
/// <param name="port">The port number</param>
/// <returns>The stored args hash, or empty string if not found</returns>
string GetStoredArgsHash();
string GetStoredArgsHash(int port);

/// <summary>
/// Clears all PID tracking information from EditorPrefs.
/// Clears handshake and tracking information for a port, and the launch marker if it
/// points at that port. Other ports and other projects are untouched.
/// </summary>
void ClearTracking();
/// <param name="port">The port number</param>
void ClearTracking(int port);

/// <summary>
/// Computes a short hash of the input string for fingerprinting.
Expand Down
Loading
Loading