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
15 changes: 14 additions & 1 deletion Packages/src/Editor/Config/McpEditorSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ public record McpEditorSettingsData
public bool installSkillsFlat = true;

// Session State Settings (moved from McpSessionManager)
// Default to true so the server starts automatically on fresh install
// This is the desired server state: automatic recovery failures preserve true,
// while an intentional stop changes it to false.
public bool isServerRunning = true;
public bool isAfterCompile = false;
public bool isDomainReloadInProgress = false;
Expand Down Expand Up @@ -569,6 +570,18 @@ public static void ClearServerSession()
});
}

/// <summary>
/// Preserves the user's request for an active server after an automatic recovery failure.
/// </summary>
public static void MarkServerRecoveryPending()
{
UpdateSettings(settings => settings with
{
isServerRunning = true,
serverSessionId = string.Empty
});
}

/// <summary>
/// Clear after compile flag.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEditor;

namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Periodically reconciles persisted server intent with the server owned by this Unity editor.
/// </summary>
[InitializeOnLoad]
public static class ServerStateWatchdog
{
private static DateTime? lastCheckUtc;
private static DateTime? lastRecoveryAttemptUtc;

static ServerStateWatchdog()
{
if (AssetDatabase.IsAssetImportWorkerProcess())
{
return;
}

EditorApplication.update += OnEditorUpdate;
}

private static void OnEditorUpdate()
{
DateTime nowUtc = DateTime.UtcNow;
if (lastCheckUtc.HasValue &&
nowUtc - lastCheckUtc.Value < TimeSpan.FromSeconds(McpConstants.SERVER_STATE_WATCHDOG_INTERVAL_SECONDS))
{
return;
}

lastCheckUtc = nowUtc;
if (McpServerController.RecoveryTask != null && !McpServerController.RecoveryTask.IsCompleted)
{
return;
}

bool serverIsRunning = McpServerController.IsServerRunning;
WatchdogObservation observation = new(
McpEditorSettings.GetIsServerRunning(),
serverIsRunning,
McpEditorSettings.GetCustomPort(),
serverIsRunning ? McpServerController.ServerPort : null,
nowUtc,
lastRecoveryAttemptUtc,
McpServerController.IsStartupProtectionActive());
WatchdogAction action = ServerStateWatchdogService.DecideAction(observation);

if (action == WatchdogAction.RecoverServer)
{
lastRecoveryAttemptUtc = nowUtc;
Task recoveryTask = McpServerController.StartRecoveryIfNeededAsync(
observation.settingsPort,
false,
CancellationToken.None);
_ = recoveryTask.ContinueWith(
task => LogRecoveryFailure(task),
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted,
TaskScheduler.FromCurrentSynchronizationContext());
return;
}

if (action == WatchdogAction.RewriteSettings)
{
McpServerController.SynchronizeRunningServerSettings();
}
}

private static void LogRecoveryFailure(Task recoveryTask)
{
VibeLogger.LogError(
"server_watchdog_recovery_failed",
recoveryTask.Exception?.GetBaseException().Message);
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using System;

namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Decides whether the persisted server state needs recovery or synchronization.
/// </summary>
public static class ServerStateWatchdogService
{
public const int RECOVERY_RETRY_INTERVAL_SECONDS = 30;

/// <summary>
/// Determines the action required to reconcile persisted and observed server state.
/// </summary>
public static WatchdogAction DecideAction(WatchdogObservation observation)
{
if (!observation.settingsClaimServerRunning)
{
return WatchdogAction.None;
}

if (observation.isStartupProtectionActive || observation.isBackgroundProcess)
{
return WatchdogAction.None;
}

if (observation.serverIsRunning)
{
bool hasPortMismatch = observation.serverPort.HasValue &&
observation.serverPort.Value != observation.settingsPort;
return hasPortMismatch ? WatchdogAction.RewriteSettings : WatchdogAction.None;
}

if (observation.lastRecoveryAttemptUtc.HasValue &&
observation.currentUtc - observation.lastRecoveryAttemptUtc.Value < TimeSpan.FromSeconds(RECOVERY_RETRY_INTERVAL_SECONDS))
{
return WatchdogAction.None;
}

return WatchdogAction.RecoverServer;
}
}

/// <summary>
/// Describes the reconciliation action selected by the watchdog.
/// </summary>
public enum WatchdogAction
{
None,
RecoverServer,
RewriteSettings
}

/// <summary>
/// Immutable snapshot used to make watchdog decisions without side effects.
/// </summary>
public sealed class WatchdogObservation
{
public readonly bool settingsClaimServerRunning;
public readonly bool serverIsRunning;
public readonly int settingsPort;
public readonly int? serverPort;
public readonly DateTime currentUtc;
public readonly DateTime? lastRecoveryAttemptUtc;
public readonly bool isStartupProtectionActive;
public readonly bool isBackgroundProcess;

public WatchdogObservation(
bool settingsClaimServerRunning,
bool serverIsRunning,
int settingsPort,
int? serverPort,
DateTime currentUtc,
DateTime? lastRecoveryAttemptUtc,
bool isStartupProtectionActive = false,
bool isBackgroundProcess = false)
{
this.settingsClaimServerRunning = settingsClaimServerRunning;
this.serverIsRunning = serverIsRunning;
this.settingsPort = settingsPort;
this.serverPort = serverPort;
this.currentUtc = currentUtc;
this.lastRecoveryAttemptUtc = lastRecoveryAttemptUtc;
this.isStartupProtectionActive = isStartupProtectionActive;
this.isBackgroundProcess = isBackgroundProcess;
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,8 @@ public static ValidationResult TryRestoreServerWithRetry(int port, int retryCoun
}
else
{
// Clear session when maximum retry count is reached
McpEditorSettings.ClearServerSession();
// Preserve the user's start intent so the watchdog can retry after the conflicting port is released.
McpEditorSettings.MarkServerRecoveryPending();
return ValidationResult.Failure($"Failed to restore server after {MAX_RETRIES} retries: {ex.Message}");
}
}
Expand Down
19 changes: 14 additions & 5 deletions Packages/src/Editor/Server/McpServerController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ private static bool IsBackgroundUnityProcess()
/// </summary>
public static int ServerPort => mcpServer?.Port ?? McpEditorSettings.GetCustomPort();

/// <summary>
/// Rewrites the persisted session to match the currently running server.
/// </summary>
internal static void SynchronizeRunningServerSettings()
{
Debug.Assert(mcpServer?.IsRunning == true, "A running server is required to synchronize settings.");
SaveRunningServerSession(mcpServer.Port);
}

/// <summary>
/// Current recovery task. Can be awaited by other components to ensure recovery completes first.
/// </summary>
Expand Down Expand Up @@ -404,8 +413,8 @@ private static void TryRestoreServerWithRetry(int port, int retryCount)
}
else
{
// If it ultimately fails, clear the SessionState.
McpEditorSettings.ClearServerSession();
// Preserve the user's start intent so the watchdog can retry after the conflicting port is released.
McpEditorSettings.MarkServerRecoveryPending();
}
}
}
Expand Down Expand Up @@ -482,7 +491,7 @@ private static void OnServerLoopUnexpectedlyExited()
"server_auto_recovery_failed",
$"Automatic recovery after unexpected exit failed: {task.Exception?.GetBaseException().Message}"
);
McpEditorSettings.ClearServerSession();
McpEditorSettings.MarkServerRecoveryPending();
}
}, TaskScheduler.FromCurrentSynchronizationContext());
};
Expand Down Expand Up @@ -812,8 +821,8 @@ public static async Task StartRecoveryIfNeededAsync(int savedPort, bool isAfterC

if (!started)
{
// Ensure session reflects stopped state on failure
McpEditorSettings.ClearServerSession();
// Preserve the user's start intent so the watchdog can retry after the conflicting port is released.
McpEditorSettings.MarkServerRecoveryPending();
McpEditorSettings.ClearReconnectingFlags();
Debug.LogError($"[{McpConstants.PROJECT_NAME}] Recovery failed: no available port to bind. SavedPort={savedPort}, LastAttemptPort={chosenPort}");
throw new InvalidOperationException($"Failed to bind any recovery port. SavedPort={savedPort}, LastAttemptPort={chosenPort}.");
Expand Down
1 change: 1 addition & 0 deletions Packages/src/Editor/Shared/Config/McpConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo

// Reconnection settings
public const int RECONNECTION_TIMEOUT_SECONDS = 10;
public const int SERVER_STATE_WATCHDOG_INTERVAL_SECONDS = 3;

// TypeScript server related constants
public const string TYPESCRIPT_SERVER_DIR = "TypeScriptServer~";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>disable</Nullable>
<IsPackable>false</IsPackable>
<LangVersion>latest</LangVersion>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
</ItemGroup>

<ItemGroup>
<Compile Include="../../Packages/src/Editor/Core/ApplicationServices/ServerStateWatchdogService.cs" Link="Production/ServerStateWatchdogService.cs" />
</ItemGroup>
</Project>
Loading