diff --git a/Assets/Tests/Demo/uLoopMCP.Tests.Demo.asmdef b/Assets/Tests/Demo/uLoopMCP.Tests.Demo.asmdef index a5dd7cbf1..489fb12e4 100644 --- a/Assets/Tests/Demo/uLoopMCP.Tests.Demo.asmdef +++ b/Assets/Tests/Demo/uLoopMCP.Tests.Demo.asmdef @@ -6,7 +6,9 @@ "GUID:c956a21f824994ef087b6de566690b3d", "GUID:4307f53044263cf4b835bd812fc161a4" ], - "includePlatforms": [], + "includePlatforms": [ + "Editor" + ], "excludePlatforms": [], "allowUnsafeCode": false, "overrideReferences": false, diff --git a/Assets/Tests/Editor/DomainReloadRecoveryUseCaseTests.cs b/Assets/Tests/Editor/DomainReloadRecoveryUseCaseTests.cs index 0c6c9d383..190728d9e 100644 --- a/Assets/Tests/Editor/DomainReloadRecoveryUseCaseTests.cs +++ b/Assets/Tests/Editor/DomainReloadRecoveryUseCaseTests.cs @@ -103,6 +103,8 @@ public void ExecuteBeforeDomainReload_ShouldPreferInstanceState_WhenInstanceIsRu // Assert Assert.IsTrue(result.Success, "ExecuteBeforeDomainReload should succeed"); Assert.IsFalse(server.IsRunning, "Running server instance should be stopped before domain reload"); + Assert.That(server.StopCallCount, Is.EqualTo(1)); + Assert.That(server.DisposeCallCount, Is.EqualTo(0)); } [Test] @@ -183,6 +185,10 @@ private sealed class TestServerInstance : IUnityCliLoopServerInstance { public bool IsRunning { get; private set; } + public int StopCallCount { get; private set; } + + public int DisposeCallCount { get; private set; } + public string Endpoint => "test"; public void StartServer() @@ -192,11 +198,13 @@ public void StartServer() public void StopServer() { + StopCallCount++; IsRunning = false; } public void Dispose() { + DisposeCallCount++; IsRunning = false; } } diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeExecutionFacadeTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeExecutionFacadeTests.cs index c8c9fcb06..2fa708a95 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeExecutionFacadeTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeExecutionFacadeTests.cs @@ -70,6 +70,23 @@ await facade.ExecuteAsync( Assert.That(provider.CreatedExecutors[0].DisposeCallCount, Is.EqualTo(1)); } + [Test] + public void ResetServerScopedServicesBeforeDomainReload_ShouldSignalShutdownWithoutWaitingForRuntimeDrain() + { + // Tests that domain reload reset does not leave a pending drain task that can block Unity teardown. + DynamicCodeServicesRegistry registry = new(); + FakeShutdownAwareRuntime runtime = new(); + registry.SetRuntimeFacadeForTests(runtime); + + registry.ResetServerScopedServicesBeforeDomainReload(); + + Assert.That(runtime.ShutdownCallCount, Is.EqualTo(1)); + Assert.That(runtime.DisposeCallCount, Is.EqualTo(0)); + Assert.That(registry.GetServerScopedDrainTaskForTests().IsCompleted, Is.True); + + runtime.CompleteShutdown(); + } + private static DynamicCodeExecutionRequest CreateRequest( DynamicCodeSecurityLevel securityLevel, string code) @@ -138,5 +155,47 @@ public void Dispose() } } + /// + /// Test support type used by editor and play mode fixtures. + /// + private sealed class FakeShutdownAwareRuntime : IShutdownAwareDynamicCodeExecutionRuntime, System.IDisposable + { + private readonly TaskCompletionSource _shutdownCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public int ShutdownCallCount { get; private set; } + + public int DisposeCallCount { get; private set; } + + public Task ExecuteAsync( + DynamicCodeExecutionRequest request, + CancellationToken cancellationToken = default) + { + throw new System.NotSupportedException(); + } + + public Task<(bool Entered, ExecutionResult Result)> TryExecuteIfIdleAsync( + DynamicCodeExecutionRequest request, + CancellationToken cancellationToken = default) + { + throw new System.NotSupportedException(); + } + + public Task ShutdownAsync() + { + ShutdownCallCount++; + return _shutdownCompletionSource.Task; + } + + public void CompleteShutdown() + { + _shutdownCompletionSource.SetResult(true); + } + + public void Dispose() + { + DisposeCallCount++; + } + } + } } diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/ExternalCompilerPathResolverTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/ExternalCompilerPathResolverTests.cs index e2c34cb17..cdcd0beed 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/ExternalCompilerPathResolverTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/ExternalCompilerPathResolverTests.cs @@ -73,6 +73,7 @@ public void ResolveScriptingRootPath_WhenLegacyLayoutExists_ShouldReturnContents string contentsPath = CreateDirectory("Contents"); CreateDirectory(Path.Combine("Contents", "NetCoreRuntime")); CreateDirectory(Path.Combine("Contents", "DotNetSdkRoslyn")); + CreateFile(Path.Combine("Contents", "DotNetSdkRoslyn", "csc.dll")); string resolvedScriptingRootPath = ExternalCompilerPathResolver.ResolveScriptingRootPath(contentsPath); @@ -82,10 +83,26 @@ public void ResolveScriptingRootPath_WhenLegacyLayoutExists_ShouldReturnContents [Test] public void ResolveScriptingRootPath_WhenResourcesScriptingLayoutExists_ShouldReturnResourcesScriptingPath() { + // Verifies Unity's Resources/Scripting compiler layout is preferred when present. string contentsPath = CreateDirectory("Contents"); string expectedScriptingRootPath = CreateDirectory(Path.Combine("Contents", "Resources", "Scripting")); CreateDirectory(Path.Combine("Contents", "Resources", "Scripting", "NetCoreRuntime")); CreateDirectory(Path.Combine("Contents", "Resources", "Scripting", "DotNetSdkRoslyn")); + CreateFile(Path.Combine("Contents", "Resources", "Scripting", "DotNetSdkRoslyn", "csc.dll")); + + string resolvedScriptingRootPath = ExternalCompilerPathResolver.ResolveScriptingRootPath(contentsPath); + + Assert.That(resolvedScriptingRootPath, Is.EqualTo(expectedScriptingRootPath)); + } + + [Test] + public void ResolveScriptingRootPath_WhenResourcesScriptingDotNetSdkLayoutExists_ShouldReturnResourcesScriptingPath() + { + // Verifies Unity 6.5 DotNetSdk compiler layouts are accepted under Resources/Scripting. + string contentsPath = CreateDirectory("Contents"); + string expectedScriptingRootPath = CreateDirectory(Path.Combine("Contents", "Resources", "Scripting")); + CreateDirectory(Path.Combine("Contents", "Resources", "Scripting", "NetCoreRuntime")); + CreateDirectory(Path.Combine("Contents", "Resources", "Scripting", "DotNetSdk", "sdk", "8.0.318", "Roslyn", "bincore")); string resolvedScriptingRootPath = ExternalCompilerPathResolver.ResolveScriptingRootPath(contentsPath); @@ -95,12 +112,15 @@ public void ResolveScriptingRootPath_WhenResourcesScriptingLayoutExists_ShouldRe [Test] public void ResolveScriptingRootPath_WhenBothLayoutsExist_ShouldPreferResourcesScriptingLayout() { + // Verifies the current Resources/Scripting layout wins over the legacy contents-root layout. string contentsPath = CreateDirectory("Contents"); CreateDirectory(Path.Combine("Contents", "NetCoreRuntime")); CreateDirectory(Path.Combine("Contents", "DotNetSdkRoslyn")); + CreateFile(Path.Combine("Contents", "DotNetSdkRoslyn", "csc.dll")); string expectedScriptingRootPath = CreateDirectory(Path.Combine("Contents", "Resources", "Scripting")); CreateDirectory(Path.Combine("Contents", "Resources", "Scripting", "NetCoreRuntime")); CreateDirectory(Path.Combine("Contents", "Resources", "Scripting", "DotNetSdkRoslyn")); + CreateFile(Path.Combine("Contents", "Resources", "Scripting", "DotNetSdkRoslyn", "csc.dll")); string resolvedScriptingRootPath = ExternalCompilerPathResolver.ResolveScriptingRootPath(contentsPath); @@ -114,17 +134,66 @@ public void ResolveScriptingRootPath_WhenKnownLayoutsAreMissing_ShouldDiscoverNe string expectedScriptingRootPath = CreateDirectory(Path.Combine("Contents", "PlaybackEngines", "Custom", "Scripting")); CreateDirectory(Path.Combine("Contents", "PlaybackEngines", "Custom", "Scripting", "NetCoreRuntime")); CreateDirectory(Path.Combine("Contents", "PlaybackEngines", "Custom", "Scripting", "DotNetSdkRoslyn")); + CreateFile(Path.Combine("Contents", "PlaybackEngines", "Custom", "Scripting", "DotNetSdkRoslyn", "csc.dll")); string resolvedScriptingRootPath = ExternalCompilerPathResolver.ResolveScriptingRootPath(contentsPath); Assert.That(resolvedScriptingRootPath, Is.EqualTo(expectedScriptingRootPath)); } + [Test] + public void ResolveCompilerDirectoryPath_WhenLegacyLayoutExists_ShouldReturnDotNetSdkRoslynPath() + { + // Verifies legacy compiler roots keep resolving to DotNetSdkRoslyn. + string scriptingRootPath = CreateDirectory("Scripting"); + string expectedCompilerDirectoryPath = CreateDirectory(Path.Combine("Scripting", "DotNetSdkRoslyn")); + CreateFile(Path.Combine("Scripting", "DotNetSdkRoslyn", "csc.dll")); + + string resolvedCompilerDirectoryPath = ExternalCompilerPathResolver.ResolveCompilerDirectoryPath(scriptingRootPath); + + Assert.That(resolvedCompilerDirectoryPath, Is.EqualTo(expectedCompilerDirectoryPath)); + } + + [Test] + public void ResolveCompilerDirectoryPath_WhenLegacyLayoutIsIncomplete_ShouldUseDotNetSdkLayout() + { + // Verifies stale legacy compiler roots fall back to the versioned DotNetSdk layout. + string scriptingRootPath = CreateDirectory("Scripting"); + CreateDirectory(Path.Combine("Scripting", "DotNetSdkRoslyn")); + string expectedCompilerDirectoryPath = CreateDirectory(Path.Combine("Scripting", "DotNetSdk", "sdk", "8.0.318", "Roslyn", "bincore")); + + string resolvedCompilerDirectoryPath = ExternalCompilerPathResolver.ResolveCompilerDirectoryPath(scriptingRootPath); + + Assert.That(resolvedCompilerDirectoryPath, Is.EqualTo(expectedCompilerDirectoryPath)); + } + + [Test] + public void ResolveCompilerDirectoryPath_WhenDotNetSdkLayoutHasMultipleSdkVersions_ShouldChooseHighestSdkRoslynBincorePath() + { + // Verifies Unity 6.5 SDK layouts choose the newest versioned Roslyn compiler directory. + string scriptingRootPath = CreateDirectory("Scripting"); + CreateDirectory(Path.Combine("Scripting", "DotNetSdk", "sdk", "8.0.100", "Roslyn", "bincore")); + string expectedCompilerDirectoryPath = CreateDirectory(Path.Combine("Scripting", "DotNetSdk", "sdk", "8.0.318", "Roslyn", "bincore")); + CreateDirectory(Path.Combine("Scripting", "DotNetSdk", "sdk", "current", "Roslyn", "bincore")); + + string resolvedCompilerDirectoryPath = ExternalCompilerPathResolver.ResolveCompilerDirectoryPath(scriptingRootPath); + + Assert.That(resolvedCompilerDirectoryPath, Is.EqualTo(expectedCompilerDirectoryPath)); + } + private string CreateDirectory(string relativePath) { string directoryPath = Path.Combine(_tempDirectoryPath, relativePath); Directory.CreateDirectory(directoryPath); return directoryPath; } + + private string CreateFile(string relativePath) + { + string filePath = Path.Combine(_tempDirectoryPath, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)); + File.WriteAllText(filePath, string.Empty); + return filePath; + } } } diff --git a/Assets/Tests/Editor/FindGameObjectsToolTests.cs b/Assets/Tests/Editor/FindGameObjectsToolTests.cs index a13e904ce..84d00a8f8 100644 --- a/Assets/Tests/Editor/FindGameObjectsToolTests.cs +++ b/Assets/Tests/Editor/FindGameObjectsToolTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.IO; using System.Threading.Tasks; using NUnit.Framework; @@ -640,11 +641,13 @@ public async Task ExecuteAsync_ReturnsObjectReferenceProperties() Assert.That(probeAnchor, Is.Not.Null, "MeshRenderer should have Probe Anchor property"); Assert.That(probeAnchor.type, Is.EqualTo("ObjectReference")); - // Value should be a structured object with name, type, instanceId + string expectedEntityId = GetExpectedObjectId(anchorTarget.transform); + + // Value should be a structured object with name, type, entityId JObject valueObj = JObject.FromObject(probeAnchor.value); Assert.That(valueObj["name"].ToString(), Is.EqualTo("AnchorTarget")); Assert.That(valueObj["type"].ToString(), Is.EqualTo("Transform")); - Assert.That(valueObj["instanceId"].Value(), Is.EqualTo(anchorTarget.transform.GetInstanceID())); + Assert.That(valueObj["entityId"].ToString(), Is.EqualTo(expectedEntityId)); } finally { @@ -682,7 +685,19 @@ public async Task ExecuteAsync_ReturnsNoneForUnsetObjectReference() JObject valueObj = JObject.FromObject(probeAnchor.value); Assert.That(valueObj["name"].ToString(), Is.EqualTo("None")); Assert.That(valueObj["type"].ToString(), Is.EqualTo("None")); - Assert.That(valueObj["instanceId"].Value(), Is.EqualTo(0)); + Assert.That(valueObj["entityId"].ToString(), Is.EqualTo("0")); + } + + private static string GetExpectedObjectId(Object obj) + { + UnityEngine.Debug.Assert(obj != null, "Unity Object must exist before reading its identifier."); + +#if UNITY_6000_4_OR_NEWER + return obj.GetEntityId().ToString(); +#else + int instanceId = obj.GetInstanceID(); + return instanceId.ToString(CultureInfo.InvariantCulture); +#endif } [Test] diff --git a/Assets/Tests/Editor/HierarchySerializerTests.cs b/Assets/Tests/Editor/HierarchySerializerTests.cs index d3b3143e5..e411ba19a 100644 --- a/Assets/Tests/Editor/HierarchySerializerTests.cs +++ b/Assets/Tests/Editor/HierarchySerializerTests.cs @@ -24,8 +24,8 @@ public void BuildGroups_WithValidNodes_ReturnsCorrectGroups() // Arrange List nodes = new() { - new(1, "Root", null, 0, true, new[] { "Transform" }, "SceneA"), - new(2, "Child", 1, 1, true, new[] { "Transform", "MeshRenderer" }, "SceneA") + new("1", "Root", null, 0, true, new[] { "Transform" }, "SceneA"), + new("2", "Child", "1", 1, true, new[] { "Transform", "MeshRenderer" }, "SceneA") }; HierarchyContext context = new("editor", "TestScene", 0, 0); @@ -77,10 +77,10 @@ public void BuildGroups_CalculatesCorrectMaxDepth() // Arrange List nodes = new() { - new(1, "Root", null, 0, true, new string[0]), - new(2, "Level1", 1, 1, true, new string[0]), - new(3, "Level2", 2, 2, true, new string[0]), - new(4, "Level3", 3, 3, true, new string[0]) + new("1", "Root", null, 0, true, new string[0]), + new("2", "Level1", "1", 1, true, new string[0]), + new("3", "Level2", "2", 2, true, new string[0]), + new("4", "Level3", "3", 3, true, new string[0]) }; HierarchyContext context = new("editor", "DeepScene", 0, 0); @@ -92,4 +92,4 @@ public void BuildGroups_CalculatesCorrectMaxDepth() Assert.That(result.Context.maxDepth, Is.EqualTo(3)); } } -} \ No newline at end of file +} diff --git a/Assets/Tests/Editor/HierarchyServiceTests.cs b/Assets/Tests/Editor/HierarchyServiceTests.cs index b69720614..293a8324d 100644 --- a/Assets/Tests/Editor/HierarchyServiceTests.cs +++ b/Assets/Tests/Editor/HierarchyServiceTests.cs @@ -133,6 +133,22 @@ public void GetHierarchyNodes_WithComponents_IncludesComponentNames() Assert.That(rootNode.components, Contains.Item("Rigidbody")); Assert.That(rootNode.components, Contains.Item("Transform")); } + +#if UNITY_6000_4_OR_NEWER + [Test] + public void GetHierarchyNodes_WithEntityId_UsesUnityEntityIdString() + { + // Verifies Unity 6.0.4+ hierarchy IDs use the EntityId public string representation. + HierarchyOptions options = new(); + string expectedObjectId = testRoot.GetEntityId().ToString(); + + List nodes = service.GetHierarchyNodes(options); + + HierarchyNode rootNode = nodes.Find(n => n.name == testRoot.name); + Assert.That(rootNode, Is.Not.Null); + Assert.That(rootNode.id, Is.EqualTo(expectedObjectId)); + } +#endif [Test] public void GetHierarchyNodes_WithRootPathIncludingRootName_ReturnsChild() @@ -219,4 +235,4 @@ public void GetHierarchyNodes_WithUseSelectionAndParentChildSelection_FiltersDes Assert.That(childNode.parent, Is.EqualTo(rootNode.id), "Child should be traversed as descendant of root, not as separate root"); } } -} \ No newline at end of file +} diff --git a/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs b/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs index 787490ed5..57eb54f14 100644 --- a/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs +++ b/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs @@ -93,6 +93,35 @@ public void CliConstants_WhenLoaded_CompileUnderDomainAssembly() Assert.That(constantsAssemblyName, Is.EqualTo(DomainAssemblyName)); } + [Test] + public void RuntimeAsmdef_WhenLoaded_TargetsEditorOnly() + { + // Tests that runtime overlay code remains excluded from player builds. + string[] includePlatforms = ReadIncludePlatforms("Packages/src/Runtime/uLoopMCP.Runtime.asmdef"); + + Assert.That(includePlatforms, Is.EquivalentTo(new[] { "Editor" })); + } + + [Test] + public void ProjectAsmdefsReferencingEditorOnlyPackageAssemblies_WhenLoaded_TargetEditorOnly() + { + // Tests that assemblies depending on package editor code stay out of player builds. + HashSet editorOnlyPackageAssemblyNames = ReadProductionPackageAsmdefPaths() + .Where(IsEditorOnlyAsmdef) + .Select(ReadAsmdefName) + .ToHashSet(StringComparer.Ordinal); + + string[] offendingAssemblyNames = ReadProjectAsmdefPaths() + .Where(path => !IsUnityIncludeTestsAsmdef(path)) + .Where(path => !IsEditorOnlyAsmdef(path)) + .Where(path => ReadResolvedReferencesFromAbsolutePath(path).Any(editorOnlyPackageAssemblyNames.Contains)) + .Select(ReadAsmdefName) + .OrderBy(assemblyName => assemblyName) + .ToArray(); + + Assert.That(offendingAssemblyNames, Is.Empty); + } + [Test] public void CompilationDiagnosticMessageParser_WhenLoaded_CompilesUnderDomainAssembly() { @@ -1187,12 +1216,43 @@ private static string[] ReadRawReferences(string asmdefPath) return asmdef["references"]?.Values().ToArray() ?? new string[0]; } + private static string[] ReadIncludePlatforms(string relativeAsmdefPath) + { + string asmdefPath = Path.Combine(UnityCliLoopPathResolver.GetProjectRoot(), relativeAsmdefPath); + return ReadIncludePlatformsFromAbsolutePath(asmdefPath); + } + + private static string[] ReadIncludePlatformsFromAbsolutePath(string asmdefPath) + { + JObject asmdef = JObject.Parse(File.ReadAllText(asmdefPath)); + return asmdef["includePlatforms"]?.Values().ToArray() ?? new string[0]; + } + + private static bool IsEditorOnlyAsmdef(string asmdefPath) + { + string[] includePlatforms = ReadIncludePlatformsFromAbsolutePath(asmdefPath); + return includePlatforms.SequenceEqual(new[] { "Editor" }); + } + + private static bool IsUnityIncludeTestsAsmdef(string asmdefPath) + { + JObject asmdef = JObject.Parse(File.ReadAllText(asmdefPath)); + string[] defineConstraints = asmdef["defineConstraints"]?.Values().ToArray() ?? new string[0]; + return defineConstraints.Contains("UNITY_INCLUDE_TESTS"); + } + private static string[] ReadProductionAsmdefPaths() { string editorRoot = Path.Combine(UnityCliLoopPathResolver.GetProjectRoot(), "Packages", "src", "Editor"); return Directory.GetFiles(editorRoot, "*.asmdef", SearchOption.AllDirectories); } + private static string[] ReadProductionPackageAsmdefPaths() + { + string packageSourceRoot = Path.Combine(UnityCliLoopPathResolver.GetProjectRoot(), "Packages", "src"); + return Directory.GetFiles(packageSourceRoot, "*.asmdef", SearchOption.AllDirectories); + } + private static string[] ReadPresentationSourcePaths() { string presentationRoot = Path.Combine(UnityCliLoopPathResolver.GetProjectRoot(), "Packages", "src", "Editor", "Presentation"); diff --git a/Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs b/Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs index 81b66dd49..1582ef3b9 100644 --- a/Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs +++ b/Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs @@ -103,7 +103,8 @@ public async Task StartRecoveryIfNeededAsync_WhenReadinessSucceeds_ShouldPublish new DomainReloadDetectionFileService(editorSettingsService, stateStore), editorSettingsService, stateStore, - readinessProbe); + readinessProbe, + new TestDomainReloadLifecycle()); await service.StartRecoveryIfNeededAsync(isAfterCompile: false, CancellationToken.None); @@ -155,7 +156,8 @@ private static UnityCliLoopServerControllerService CreateControllerService(TestR new DomainReloadDetectionFileService(editorSettingsService, stateStore), editorSettingsService, stateStore, - readinessProbe); + readinessProbe, + new TestDomainReloadLifecycle()); } private static ServerReadinessStateStore CreateTestStateStore() @@ -193,6 +195,16 @@ public Task ProbeAsync(CancellationToken ct) } } + /// + /// Test support type that keeps domain reload lifecycle behavior side-effect free. + /// + private sealed class TestDomainReloadLifecycle : IUnityCliLoopServerDomainReloadLifecycle + { + public void PrepareForDomainReload() + { + } + } + /// /// Test support type used by editor and play mode fixtures. /// diff --git a/Assets/Tests/Editor/UnityCliLoopServerStartupProtectionTests.cs b/Assets/Tests/Editor/UnityCliLoopServerStartupProtectionTests.cs index 32e691963..00f52a6bd 100644 --- a/Assets/Tests/Editor/UnityCliLoopServerStartupProtectionTests.cs +++ b/Assets/Tests/Editor/UnityCliLoopServerStartupProtectionTests.cs @@ -57,6 +57,18 @@ public void OnBeforeAssemblyReload_ShouldClearStartupProtectionBeforeRecovery() } } + [Test] + public void OnBeforeAssemblyReload_ShouldPrepareDomainReloadLifecycle() + { + // Tests that bundled server-scoped services are reset through the domain-reload lifecycle hook. + TestDomainReloadLifecycle domainReloadLifecycle = new(); + UnityCliLoopServerControllerService service = CreateControllerService(domainReloadLifecycle); + + service.OnBeforeAssemblyReload(); + + Assert.That(domainReloadLifecycle.PrepareCallCount, Is.EqualTo(1)); + } + [Test] public void PrepareForServerShutdown_ShouldClearStartupProtectionBeforeShutdown() { @@ -82,6 +94,11 @@ private static UnityCliLoopEditorSettingsData CloneSettings(UnityCliLoopEditorSe } private static UnityCliLoopServerControllerService CreateControllerService() + { + return CreateControllerService(new TestDomainReloadLifecycle()); + } + + private static UnityCliLoopServerControllerService CreateControllerService(TestDomainReloadLifecycle domainReloadLifecycle) { TestServerInstanceFactory serverInstanceFactory = new(); UnityCliLoopServerLifecycleRegistryService lifecycleRegistry = @@ -95,7 +112,8 @@ private static UnityCliLoopServerControllerService CreateControllerService() new DomainReloadDetectionFileService(editorSettingsService, stateStore), editorSettingsService, stateStore, - new TestReadinessProbe()); + new TestReadinessProbe(), + domainReloadLifecycle); } private static ServerReadinessStateStore CreateTestStateStore() @@ -118,6 +136,19 @@ public System.Threading.Tasks.Task ProbeAsync(System.Threading.CancellationToken } } + /// + /// Test support type that records domain reload lifecycle calls. + /// + private sealed class TestDomainReloadLifecycle : IUnityCliLoopServerDomainReloadLifecycle + { + public int PrepareCallCount { get; private set; } + + public void PrepareForDomainReload() + { + PrepareCallCount++; + } + } + /// /// Test support type used by editor and play mode fixtures. /// diff --git a/Packages/src/Cli~/dist/darwin-amd64/uloop b/Packages/src/Cli~/dist/darwin-amd64/uloop index 2f8683356..f654257f6 100755 Binary files a/Packages/src/Cli~/dist/darwin-amd64/uloop and b/Packages/src/Cli~/dist/darwin-amd64/uloop differ diff --git a/Packages/src/Cli~/dist/darwin-arm64/uloop b/Packages/src/Cli~/dist/darwin-arm64/uloop index c4e54f596..2127ab6fa 100755 Binary files a/Packages/src/Cli~/dist/darwin-arm64/uloop and b/Packages/src/Cli~/dist/darwin-arm64/uloop differ diff --git a/Packages/src/Cli~/dist/windows-amd64/uloop.exe b/Packages/src/Cli~/dist/windows-amd64/uloop.exe index 81e2ae427..3262275cb 100755 Binary files a/Packages/src/Cli~/dist/windows-amd64/uloop.exe and b/Packages/src/Cli~/dist/windows-amd64/uloop.exe differ diff --git a/Packages/src/Cli~/internal/cli/skills.go b/Packages/src/Cli~/internal/cli/skills.go index d5834b3c4..98a0084a2 100644 --- a/Packages/src/Cli~/internal/cli/skills.go +++ b/Packages/src/Cli~/internal/cli/skills.go @@ -11,15 +11,16 @@ import ( ) const ( - skillsCommandName = "skills" - managedSkillsDir = "unity-cli-loop" - skillFileName = "SKILL.md" - uloopSettingsDir = ".uloop" - toolSettingsFile = "settings.tools.json" - manifestFileName = "manifest.json" - packageName = "io.github.hatayama.uloopmcp" - packageNameAlias = "io.github.hatayama.uLoopMCP" - skillSearchMaxDepth = 3 + skillsCommandName = "skills" + managedSkillsDir = "unity-cli-loop" + skillFileName = "SKILL.md" + uloopSettingsDir = ".uloop" + toolSettingsFile = "settings.tools.json" + manifestFileName = "manifest.json" + packageName = "io.github.hatayama.uloopmcp" + packageNameAlias = "io.github.hatayama.uLoopMCP" + skillSearchMaxDepth = 3 + groupSkillsByDefault = false utf16LittleEndianBOMFirstByte = 0xff utf16LittleEndianBOMSecondByte = 0xfe @@ -185,6 +186,13 @@ func isKnownSkillsSubcommand(subcommand string) bool { } } +func groupManagedSkillsForOptions(options skillCommandOptions) bool { + if options.flat { + return false + } + return groupSkillsByDefault +} + func unknownSkillsSubcommandError(subcommand string, context errorContext) cliError { return (&argumentError{ message: "Unknown skills command: " + subcommand, @@ -239,7 +247,7 @@ func runSkillsList(projectRoot string, skills []skillDefinition, options skillCo writeFormat(stdout, "Location: %s\n", baseDir) writeLine(stdout, strings.Repeat("=", 50)) for _, skill := range skills { - status, err := getSkillStatus(baseDir, skill, !options.flat) + status, err := getSkillStatus(baseDir, skill, groupManagedSkillsForOptions(options)) if err != nil { writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) return 1 @@ -257,7 +265,7 @@ func runSkillsInstall(projectRoot string, skills []skillDefinition, options skil writeFormat(stdout, "Installing uloop skills (%s)...\n", skillLocationName(options.global)) writeLine(stdout, "") for _, target := range options.targets { - result, err := installSkillsForTarget(projectRoot, target, skills, options.global, !options.flat) + result, err := installSkillsForTarget(projectRoot, target, skills, options.global, groupManagedSkillsForOptions(options)) if err != nil { writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) return 1 @@ -284,7 +292,8 @@ func runSkillsUninstall(projectRoot string, skills []skillDefinition, options sk writeFormat(stdout, "Uninstalling uloop skills (%s)...\n", skillLocationName(options.global)) writeLine(stdout, "") for _, target := range options.targets { - removed, notFound, err := uninstallSkillsForTarget(projectRoot, target, skills, options.global, !options.flat) + grouped := groupManagedSkillsForOptions(options) + removed, notFound, err := uninstallSkillsForTarget(projectRoot, target, skills, options.global, grouped) if err != nil { writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) return 1 diff --git a/Packages/src/Cli~/internal/cli/skills_targets.go b/Packages/src/Cli~/internal/cli/skills_targets.go index 520f4f302..cd807bf73 100644 --- a/Packages/src/Cli~/internal/cli/skills_targets.go +++ b/Packages/src/Cli~/internal/cli/skills_targets.go @@ -87,12 +87,22 @@ func removeDeprecatedSkillDir(baseDir string, skillName string, grouped bool) (b } func removeSkillFromAllLayouts(baseDir string, skillName string) error { + _, err := removeSkillFromAllLayoutsIfExists(baseDir, skillName) + return err +} + +func removeSkillFromAllLayoutsIfExists(baseDir string, skillName string) (bool, error) { + removed := false for _, grouped := range []bool{true, false} { - if _, err := removeDirIfExists(getPreferredSkillDir(baseDir, skillName, grouped)); err != nil { - return err + exists, err := removeDirIfExists(getPreferredSkillDir(baseDir, skillName, grouped)) + if err != nil { + return removed, err + } + if exists { + removed = true } } - return nil + return removed, nil } func removeDirIfExists(path string) (bool, error) { diff --git a/Packages/src/Cli~/internal/cli/skills_test.go b/Packages/src/Cli~/internal/cli/skills_test.go index 1155a52a2..916c04695 100644 --- a/Packages/src/Cli~/internal/cli/skills_test.go +++ b/Packages/src/Cli~/internal/cli/skills_test.go @@ -507,6 +507,112 @@ name: uloop-sample } } +// Tests that the public install command writes skills to the flat layout by default. +func TestRunSkillsInstallDefaultsToFlatLayout(t *testing.T) { + projectRoot := t.TempDir() + sourceDir := filepath.Join(projectRoot, "source", "Skill") + writeSkillFile(t, sourceDir, `--- +name: uloop-sample +--- + +# sample +`) + + skill := skillDefinition{ + name: "uloop-sample", + content: []byte("---\nname: uloop-sample\n---\n\n# sample\n"), + sourceDirectory: sourceDir, + } + target := targetConfigs["claude"] + options := skillCommandOptions{ + targets: []skillTarget{target}, + } + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + code := runSkillsInstall(projectRoot, []skillDefinition{skill}, options, stdout, stderr) + + if code != 0 { + t.Fatalf("install should succeed: code=%d stderr=%s", code, stderr.String()) + } + baseDir, err := getSkillsBaseDir(projectRoot, target, false) + if err != nil { + t.Fatalf("getSkillsBaseDir failed: %v", err) + } + flatDir := getPreferredSkillDir(baseDir, skill.name, false) + groupedDir := getPreferredSkillDir(baseDir, skill.name, true) + if _, err := os.Stat(filepath.Join(flatDir, "SKILL.md")); err != nil { + t.Fatalf("flat skill should be installed: %v", err) + } + if _, err := os.Stat(groupedDir); err == nil { + t.Fatal("grouped skill should not be installed by default") + } +} + +// Tests that the public list command reads the flat layout by default. +func TestRunSkillsListDefaultsToFlatLayout(t *testing.T) { + projectRoot := t.TempDir() + skill := skillDefinition{ + name: "uloop-sample", + content: []byte("---\nname: uloop-sample\n---\n\n# sample\n"), + } + target := targetConfigs["claude"] + baseDir, err := getSkillsBaseDir(projectRoot, target, false) + if err != nil { + t.Fatalf("getSkillsBaseDir failed: %v", err) + } + writeSkillFile(t, getPreferredSkillDir(baseDir, skill.name, false), string(skill.content)) + options := skillCommandOptions{ + targets: []skillTarget{target}, + } + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + code := runSkillsList(projectRoot, []skillDefinition{skill}, options, stdout, stderr) + + if code != 0 { + t.Fatalf("list should succeed: code=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "uloop-sample (installed)") { + t.Fatalf("flat skill should be reported as installed: %s", stdout.String()) + } +} + +// Tests that the public uninstall command removes only the flat layout by default. +func TestRunSkillsUninstallDefaultsToFlatLayout(t *testing.T) { + projectRoot := t.TempDir() + skill := skillDefinition{ + name: "uloop-sample", + content: []byte("sample"), + } + target := targetConfigs["claude"] + baseDir, err := getSkillsBaseDir(projectRoot, target, false) + if err != nil { + t.Fatalf("getSkillsBaseDir failed: %v", err) + } + flatDir := getPreferredSkillDir(baseDir, skill.name, false) + groupedDir := getPreferredSkillDir(baseDir, skill.name, true) + writeSkillFile(t, flatDir, "# flat\n") + writeSkillFile(t, groupedDir, "# grouped\n") + options := skillCommandOptions{ + targets: []skillTarget{target}, + } + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + code := runSkillsUninstall(projectRoot, []skillDefinition{skill}, options, stdout, stderr) + + if code != 0 { + t.Fatalf("uninstall should succeed: code=%d stderr=%s", code, stderr.String()) + } + if _, err := os.Stat(flatDir); err == nil { + t.Fatal("flat skill should be removed") + } + if _, err := os.Stat(groupedDir); err != nil { + t.Fatalf("grouped skill should remain: %v", err) + } +} + // Tests that uninstalling uses only the selected layout and leaves the other layout intact. func TestUninstallSkillsForTargetUsesSelectedLayoutOnly(t *testing.T) { projectRoot := t.TempDir() diff --git a/Packages/src/Editor/Application/UnityCliLoopServerApplicationService.cs b/Packages/src/Editor/Application/UnityCliLoopServerApplicationService.cs index d74fd08e6..2d8de8428 100644 --- a/Packages/src/Editor/Application/UnityCliLoopServerApplicationService.cs +++ b/Packages/src/Editor/Application/UnityCliLoopServerApplicationService.cs @@ -38,6 +38,14 @@ public interface IUnityCliLoopServerLifecycleSource event Action ServerLoopExited; } + /// + /// Handles server-scoped cleanup that must happen before Unity tears down editor assemblies. + /// + public interface IUnityCliLoopServerDomainReloadLifecycle + { + void PrepareForDomainReload(); + } + /// /// Defines the control operations needed for Unity CLI Loop Server behavior. /// diff --git a/Packages/src/Editor/Application/UseCases/DomainReloadRecoveryUseCase.cs b/Packages/src/Editor/Application/UseCases/DomainReloadRecoveryUseCase.cs index 7beea1d85..6b60207d7 100644 --- a/Packages/src/Editor/Application/UseCases/DomainReloadRecoveryUseCase.cs +++ b/Packages/src/Editor/Application/UseCases/DomainReloadRecoveryUseCase.cs @@ -71,7 +71,7 @@ public ServiceResult ExecuteBeforeDomainReload(IUnityCliLoopServerInstan LogServerStoppingBeforeDomainReload(correlationId); // 4.2. Stop server - currentServer.Dispose(); + currentServer.StopServer(); LogServerStoppedAfterDomainReload(correlationId); diff --git a/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs b/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs index 4cd630739..4dbfc2696 100644 --- a/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs +++ b/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs @@ -17,7 +17,7 @@ internal UnityCliLoopApplicationServices Register() UnityCliLoopEditorSettingsRepository editorSettingsRepository = new(); UnityCliLoopEditorSettingsService editorSettingsService = new(editorSettingsRepository); ServerReadinessStateStore serverReadinessStateStore = new(UnityCliLoopPathResolver.GetProjectRoot()); - UnityCliLoopFirstPartyServerLifecycleBinding serverReadinessProbe = new(new ProjectIpcWarmupClient()); + UnityCliLoopFirstPartyServerLifecycleBinding firstPartyServerLifecycle = new(new ProjectIpcWarmupClient()); ULoopSettingsRepository uLoopSettingsRepository = new( toolSettingsService, editorSettingsService); @@ -53,7 +53,8 @@ internal UnityCliLoopApplicationServices Register() domainReloadDetectionService, editorSettingsService, serverReadinessStateStore, - serverReadinessProbe); + firstPartyServerLifecycle, + firstPartyServerLifecycle); UnityCliLoopServerApplicationService applicationService = new(controllerService); UnityCliLoopServerApplicationFacade.RegisterService(applicationService); controllerService.InitializeForEditorStartup(); diff --git a/Packages/src/Editor/CompositionRoot/UnityCliLoopFirstPartyServerLifecycleBinding.cs b/Packages/src/Editor/CompositionRoot/UnityCliLoopFirstPartyServerLifecycleBinding.cs index 82705faf3..28a71868c 100644 --- a/Packages/src/Editor/CompositionRoot/UnityCliLoopFirstPartyServerLifecycleBinding.cs +++ b/Packages/src/Editor/CompositionRoot/UnityCliLoopFirstPartyServerLifecycleBinding.cs @@ -14,7 +14,9 @@ namespace io.github.hatayama.UnityCliLoop.CompositionRoot /// /// Resets bundled tool lifecycle state and proves get-version IPC readiness before the server is published as ready. /// - internal sealed class UnityCliLoopFirstPartyServerLifecycleBinding : IUnityCliLoopServerReadinessProbe + internal sealed class UnityCliLoopFirstPartyServerLifecycleBinding : + IUnityCliLoopServerReadinessProbe, + IUnityCliLoopServerDomainReloadLifecycle { private readonly ProjectIpcWarmupClient _projectIpcWarmupClient; @@ -31,6 +33,11 @@ public Task ProbeAsync(CancellationToken ct) return ResetServerScopedServicesAndWarmProjectIpcAsync(ct); } + public void PrepareForDomainReload() + { + FirstPartyToolsEditorStartup.ResetServerScopedServicesBeforeDomainReload(); + } + private async Task ResetServerScopedServicesAndWarmProjectIpcAsync(CancellationToken ct) { FirstPartyToolsEditorStartup.ResetServerScopedServices(); diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeServices.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeServices.cs index b604ebac1..d30334c92 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeServices.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeServices.cs @@ -57,6 +57,38 @@ internal void ResetServerScopedServices() } } + internal void ResetServerScopedServicesBeforeDomainReload() + { + IDynamicCodeExecutionRuntime runtimeFacade; + + lock (_serverScopedServicesLock) + { + runtimeFacade = _runtimeFacade; + _runtimeFacade = null; + _serverScopedDrainTask = Task.CompletedTask; + } + + SignalRuntimeShutdownBeforeDomainReload(runtimeFacade); + SharedRoslynCompilerWorkerHost.ShutdownForServerReset(); + } + + internal void SetRuntimeFacadeForTests(IDynamicCodeExecutionRuntime runtimeFacade) + { + lock (_serverScopedServicesLock) + { + _runtimeFacade = runtimeFacade; + _serverScopedDrainTask = Task.CompletedTask; + } + } + + internal Task GetServerScopedDrainTaskForTests() + { + lock (_serverScopedServicesLock) + { + return _serverScopedDrainTask; + } + } + private IDynamicCodeExecutionRuntime GetRuntimeFacade() { lock (_serverScopedServicesLock) @@ -88,6 +120,21 @@ private static Task ShutdownRuntimeAsync(IDynamicCodeExecutionRuntime runtimeFac return Task.CompletedTask; } + private static void SignalRuntimeShutdownBeforeDomainReload(IDynamicCodeExecutionRuntime runtimeFacade) + { + if (runtimeFacade is IShutdownAwareDynamicCodeExecutionRuntime shutdownAwareRuntime) + { + Task shutdownTask = shutdownAwareRuntime.ShutdownAsync(); + _ = ObserveDrainTask(shutdownTask); + return; + } + + if (runtimeFacade is IDisposable disposableRuntime) + { + disposableRuntime.Dispose(); + } + } + private static Task ChainDrainTask(Task currentDrainTask, Task nextDrainTask) { Task observedCurrentDrainTask = ObserveDrainTask(currentDrainTask); @@ -181,5 +228,10 @@ internal static void ResetServerScopedServices() { GetRegistry().ResetServerScopedServices(); } + + internal static void ResetServerScopedServicesBeforeDomainReload() + { + GetRegistry().ResetServerScopedServicesBeforeDomainReload(); + } } } diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/ExternalCompilerPathResolver.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/ExternalCompilerPathResolver.cs index ec00c92f9..cb8787b2e 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/ExternalCompilerPathResolver.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/ExternalCompilerPathResolver.cs @@ -11,6 +11,20 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal static class ExternalCompilerPathResolver { + private const string NetCoreRuntimeDirectoryName = "NetCoreRuntime"; + private const string DotNetSdkRoslynDirectoryName = "DotNetSdkRoslyn"; + private const string DotNetSdkDirectoryName = "DotNetSdk"; + private const string DotNetSdkSdkDirectoryName = "sdk"; + private const string RoslynDirectoryName = "Roslyn"; + private const string CompilerBincoreDirectoryName = "bincore"; + private const string CompilerDllFileName = "csc.dll"; + private const string CompilerRuntimeConfigFileName = "csc.runtimeconfig.json"; + private const string CompilerDepsFileName = "csc.deps.json"; + private const string CodeAnalysisDllFileName = "Microsoft.CodeAnalysis.dll"; + private const string CodeAnalysisCSharpDllFileName = "Microsoft.CodeAnalysis.CSharp.dll"; + private const string NetCoreRuntimeSharedDirectoryName = "shared"; + private const string NetCoreRuntimeSharedFrameworkName = "Microsoft.NETCore.App"; + public static ExternalCompilerPaths Resolve() { string editorPath = EditorApplication.applicationPath; @@ -29,23 +43,34 @@ public static ExternalCompilerPaths Resolve() string dotnetHostFileName = UnityEngine.Application.platform == UnityEngine.RuntimePlatform.WindowsEditor ? "dotnet.exe" : "dotnet"; + string effectiveScriptingRootPath = scriptingRootPath ?? contentsPath; + string compilerDirectoryPath = ResolveCompilerDirectoryPath(effectiveScriptingRootPath); List missingComponents = new(); if (string.IsNullOrEmpty(scriptingRootPath)) { - missingComponents.Add(Path.Combine(contentsPath, "NetCoreRuntime")); - missingComponents.Add(Path.Combine(contentsPath, "DotNetSdkRoslyn")); - missingComponents.Add(Path.Combine(contentsPath, "Resources", "Scripting", "NetCoreRuntime")); - missingComponents.Add(Path.Combine(contentsPath, "Resources", "Scripting", "DotNetSdkRoslyn")); + missingComponents.Add(Path.Combine(contentsPath, NetCoreRuntimeDirectoryName)); + missingComponents.Add(Path.Combine(contentsPath, DotNetSdkRoslynDirectoryName)); + missingComponents.Add(Path.Combine(contentsPath, DotNetSdkDirectoryName, DotNetSdkSdkDirectoryName, "*", RoslynDirectoryName, CompilerBincoreDirectoryName)); + missingComponents.Add(Path.Combine(contentsPath, "Resources", "Scripting", NetCoreRuntimeDirectoryName)); + missingComponents.Add(Path.Combine(contentsPath, "Resources", "Scripting", DotNetSdkRoslynDirectoryName)); + missingComponents.Add(Path.Combine(contentsPath, "Resources", "Scripting", DotNetSdkDirectoryName, DotNetSdkSdkDirectoryName, "*", RoslynDirectoryName, CompilerBincoreDirectoryName)); + } + + if (string.IsNullOrEmpty(compilerDirectoryPath)) + { + compilerDirectoryPath = Path.Combine(effectiveScriptingRootPath, DotNetSdkRoslynDirectoryName); + missingComponents.Add(compilerDirectoryPath); + missingComponents.Add(Path.Combine(effectiveScriptingRootPath, DotNetSdkDirectoryName, DotNetSdkSdkDirectoryName, "*", RoslynDirectoryName, CompilerBincoreDirectoryName)); } - string dotnetHostPath = Path.Combine(scriptingRootPath ?? contentsPath, "NetCoreRuntime", dotnetHostFileName); - string compilerDllPath = Path.Combine(scriptingRootPath ?? contentsPath, "DotNetSdkRoslyn", "csc.dll"); - string compilerRuntimeConfigPath = Path.Combine(scriptingRootPath ?? contentsPath, "DotNetSdkRoslyn", "csc.runtimeconfig.json"); - string compilerDepsFilePath = Path.Combine(scriptingRootPath ?? contentsPath, "DotNetSdkRoslyn", "csc.deps.json"); - string codeAnalysisDllPath = Path.Combine(scriptingRootPath ?? contentsPath, "DotNetSdkRoslyn", "Microsoft.CodeAnalysis.dll"); - string codeAnalysisCSharpDllPath = Path.Combine(scriptingRootPath ?? contentsPath, "DotNetSdkRoslyn", "Microsoft.CodeAnalysis.CSharp.dll"); - string netCoreRuntimeSharedRootPath = Path.Combine(scriptingRootPath ?? contentsPath, "NetCoreRuntime", "shared", "Microsoft.NETCore.App"); + string dotnetHostPath = Path.Combine(effectiveScriptingRootPath, NetCoreRuntimeDirectoryName, dotnetHostFileName); + string compilerDllPath = Path.Combine(compilerDirectoryPath, CompilerDllFileName); + string compilerRuntimeConfigPath = Path.Combine(compilerDirectoryPath, CompilerRuntimeConfigFileName); + string compilerDepsFilePath = Path.Combine(compilerDirectoryPath, CompilerDepsFileName); + string codeAnalysisDllPath = Path.Combine(compilerDirectoryPath, CodeAnalysisDllFileName); + string codeAnalysisCSharpDllPath = Path.Combine(compilerDirectoryPath, CodeAnalysisCSharpDllFileName); + string netCoreRuntimeSharedRootPath = Path.Combine(effectiveScriptingRootPath, NetCoreRuntimeDirectoryName, NetCoreRuntimeSharedDirectoryName, NetCoreRuntimeSharedFrameworkName); string netCoreRuntimeSharedDirectoryPath = ResolveNetCoreRuntimeSharedDirectoryPath(netCoreRuntimeSharedRootPath); if (!File.Exists(dotnetHostPath)) @@ -162,8 +187,80 @@ internal static string ResolveNetCoreRuntimeSharedDirectoryPath(string netCoreRu private static bool ContainsExternalCompilerLayout(string rootPath) { - return Directory.Exists(Path.Combine(rootPath, "NetCoreRuntime")) - && Directory.Exists(Path.Combine(rootPath, "DotNetSdkRoslyn")); + return Directory.Exists(Path.Combine(rootPath, NetCoreRuntimeDirectoryName)) + && !string.IsNullOrEmpty(ResolveCompilerDirectoryPath(rootPath)); + } + + internal static string ResolveCompilerDirectoryPath(string scriptingRootPath) + { + if (string.IsNullOrEmpty(scriptingRootPath)) + { + return null; + } + + string legacyCompilerDirectoryPath = Path.Combine(scriptingRootPath, DotNetSdkRoslynDirectoryName); + if (IsUsableCompilerDirectory(legacyCompilerDirectoryPath)) + { + return legacyCompilerDirectoryPath; + } + + return ResolveDotNetSdkCompilerDirectoryPath(scriptingRootPath); + } + + private static bool IsUsableCompilerDirectory(string compilerDirectoryPath) + { + return Directory.Exists(compilerDirectoryPath) + && File.Exists(Path.Combine(compilerDirectoryPath, CompilerDllFileName)); + } + + private static string ResolveDotNetSdkCompilerDirectoryPath(string scriptingRootPath) + { + string sdkRootPath = Path.Combine(scriptingRootPath, DotNetSdkDirectoryName, DotNetSdkSdkDirectoryName); + if (!Directory.Exists(sdkRootPath)) + { + return null; + } + + List sdkDirectoryPaths = Directory.GetDirectories(sdkRootPath).ToList(); + sdkDirectoryPaths.Sort(CompareSdkDirectoryPathsDescending); + + foreach (string sdkDirectoryPath in sdkDirectoryPaths) + { + string compilerDirectoryPath = Path.Combine(sdkDirectoryPath, RoslynDirectoryName, CompilerBincoreDirectoryName); + if (Directory.Exists(compilerDirectoryPath)) + { + return compilerDirectoryPath; + } + } + + return null; + } + + private static int CompareSdkDirectoryPathsDescending(string leftPath, string rightPath) + { + string leftVersionText = Path.GetFileName(leftPath); + string rightVersionText = Path.GetFileName(rightPath); + bool leftIsVersion = Version.TryParse(leftVersionText, out Version leftVersion); + bool rightIsVersion = Version.TryParse(rightVersionText, out Version rightVersion); + + if (leftIsVersion && rightIsVersion) + { + int versionComparison = rightVersion.CompareTo(leftVersion); + if (versionComparison != 0) + { + return versionComparison; + } + } + else if (leftIsVersion) + { + return -1; + } + else if (rightIsVersion) + { + return 1; + } + + return string.Compare(rightVersionText, leftVersionText, StringComparison.Ordinal); } private static string ResolveScriptingRootPathByScan(string contentsPath) diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeEditorStartup.cs index a2570c750..48f19353c 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeEditorStartup.cs @@ -18,5 +18,10 @@ internal static void ResetServerScopedServices() { DynamicCodeServices.ResetServerScopedServices(); } + + internal static void ResetServerScopedServicesBeforeDomainReload() + { + DynamicCodeServices.ResetServerScopedServicesBeforeDomainReload(); + } } } diff --git a/Packages/src/Editor/FirstPartyTools/FindGameObjects/GameObjectFinder/ComponentPropertySerializer.cs b/Packages/src/Editor/FirstPartyTools/FindGameObjects/GameObjectFinder/ComponentPropertySerializer.cs index 72df09796..358296411 100644 --- a/Packages/src/Editor/FirstPartyTools/FindGameObjects/GameObjectFinder/ComponentPropertySerializer.cs +++ b/Packages/src/Editor/FirstPartyTools/FindGameObjects/GameObjectFinder/ComponentPropertySerializer.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Reflection; using UnityEngine; using UnityEditor; @@ -84,21 +85,61 @@ private object GetSerializedPropertyValue(SerializedProperty property) case SerializedPropertyType.LayerMask: return property.intValue; case SerializedPropertyType.ObjectReference: - UnityEngine.Object obj = property.objectReferenceValue; - if (obj == null) - { - // objectReferenceInstanceIDValue != 0 means a broken (Missing) reference - if (property.objectReferenceInstanceIDValue != 0) - { - return new { name = "Missing", type = "Missing", instanceId = property.objectReferenceInstanceIDValue }; - } - return new { name = "None", type = "None", instanceId = 0 }; - } - return new { name = obj.name, type = obj.GetType().Name, instanceId = obj.GetInstanceID() }; + return GetObjectReferenceValue(property); default: return null; // Unsupported property types } } + + private object GetObjectReferenceValue(SerializedProperty property) + { + UnityEngine.Debug.Assert(property != null, "SerializedProperty must exist before reading object references."); + UnityEngine.Debug.Assert(property.propertyType == SerializedPropertyType.ObjectReference, "Object reference serialization requires an ObjectReference property."); + + UnityEngine.Object obj = property.objectReferenceValue; + if (obj == null) + { + if (HasStoredObjectReferenceId(property)) + { + return new { name = "Missing", type = "Missing", entityId = GetStoredObjectReferenceId(property) }; + } + + return new { name = "None", type = "None", entityId = "0" }; + } + + return new { name = obj.name, type = obj.GetType().Name, entityId = GetObjectId(obj) }; + } + + private static bool HasStoredObjectReferenceId(SerializedProperty property) + { +#if UNITY_6000_4_OR_NEWER + return property.objectReferenceEntityIdValue != UnityEngine.EntityId.None; +#else + return property.objectReferenceInstanceIDValue != 0; +#endif + } + + private static string GetStoredObjectReferenceId(SerializedProperty property) + { +#if UNITY_6000_4_OR_NEWER + return property.objectReferenceEntityIdValue.ToString(); +#else + int instanceId = property.objectReferenceInstanceIDValue; + return instanceId.ToString(CultureInfo.InvariantCulture); +#endif + } + + private static string GetObjectId(UnityEngine.Object obj) + { + UnityEngine.Debug.Assert(obj != null, "Unity Object must exist before reading its identifier."); + +#if UNITY_6000_4_OR_NEWER + return obj.GetEntityId().ToString(); +#else + int instanceId = obj.GetInstanceID(); + return instanceId.ToString(CultureInfo.InvariantCulture); +#endif + } private object SerializeValue(object value) @@ -163,4 +204,4 @@ private object SerializeValue(object value) return value; } } -} \ No newline at end of file +} diff --git a/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs index d613ee74a..1a648b7ac 100644 --- a/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs @@ -24,6 +24,11 @@ public static void ResetServerScopedServices() ExecuteDynamicCodeEditorStartup.ResetServerScopedServices(); } + public static void ResetServerScopedServicesBeforeDomainReload() + { + ExecuteDynamicCodeEditorStartup.ResetServerScopedServicesBeforeDomainReload(); + } + public static string CreateExecuteDynamicCodeReadinessProbeCode() { // Why: composition root can only depend on the bundled-tool facade assembly, diff --git a/Packages/src/Editor/FirstPartyTools/GetHierarchy/HierarchyAnalyzer/HierarchyNode.cs b/Packages/src/Editor/FirstPartyTools/GetHierarchy/HierarchyAnalyzer/HierarchyNode.cs index 0854de6c2..5455a1330 100644 --- a/Packages/src/Editor/FirstPartyTools/GetHierarchy/HierarchyAnalyzer/HierarchyNode.cs +++ b/Packages/src/Editor/FirstPartyTools/GetHierarchy/HierarchyAnalyzer/HierarchyNode.cs @@ -10,9 +10,9 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools public class HierarchyNode { /// - /// Unity's GetInstanceID() value - unique within session + /// Session-unique Unity object identifier /// - public readonly int id; + public readonly string id; /// /// GameObject name @@ -20,9 +20,9 @@ public class HierarchyNode public readonly string name; /// - /// Parent node's instance ID (null for root objects) + /// Parent node's session-unique Unity object identifier (null for root objects) /// - public readonly int? parent; + public readonly string parent; /// /// Depth level in hierarchy (0 for root) @@ -52,9 +52,9 @@ public class HierarchyNode /// /// Constructor for HierarchyNode /// - public HierarchyNode(int id, string name, int? parent, int depth, bool isActive, string[] components, string sceneName = "", int? siblingIndex = null, string tag = null, int? layer = null) + public HierarchyNode(string id, string name, string parent, int depth, bool isActive, string[] components, string sceneName = "", int? siblingIndex = null, string tag = null, int? layer = null) { - this.id = id; + this.id = id ?? string.Empty; this.name = name ?? string.Empty; this.parent = parent; this.depth = depth; @@ -66,4 +66,4 @@ public HierarchyNode(int id, string name, int? parent, int depth, bool isActive, this.layer = layer; } } -} \ No newline at end of file +} diff --git a/Packages/src/Editor/FirstPartyTools/GetHierarchy/HierarchyAnalyzer/HierarchySerializer.cs b/Packages/src/Editor/FirstPartyTools/GetHierarchy/HierarchyAnalyzer/HierarchySerializer.cs index 42bc97d7a..3ac662151 100644 --- a/Packages/src/Editor/FirstPartyTools/GetHierarchy/HierarchyAnalyzer/HierarchySerializer.cs +++ b/Packages/src/Editor/FirstPartyTools/GetHierarchy/HierarchyAnalyzer/HierarchySerializer.cs @@ -35,7 +35,7 @@ public HierarchySerializationResult BuildGroups(List nodes, Hiera ); // Group by scene - var groups = nodes + List groups = nodes .GroupBy(n => n.sceneName) .Select(g => BuildGroupForScene(g.Key ?? string.Empty, g.ToList(), options)) .ToList(); @@ -46,7 +46,7 @@ public HierarchySerializationResult BuildGroups(List nodes, Hiera private SceneHierarchyGroup BuildGroupForScene(string sceneName, List sceneNodes, HierarchySerializationOptions options) { // Build nested structure per scene - Dictionary nodeDict = new(); + Dictionary nodeDict = new(); foreach (HierarchyNode flat in sceneNodes) { @@ -70,7 +70,7 @@ private SceneHierarchyGroup BuildGroupForScene(string sceneName, List all = new(); - foreach (var n in sceneNodes) + foreach (HierarchyNode node in sceneNodes) { - if (n.components != null && n.components.Length > 0) + if (node.components != null && node.components.Length > 0) { - all.AddRange(n.components); + all.AddRange(node.components); } } if (all.Count < 50) return false; @@ -121,7 +121,7 @@ private static bool ShouldUseComponentsLut(HierarchySerializationOptions options return unique * 2 < all.Count; // more than 50% duplicates } - private static List BuildComponentsLutAndApply(List sceneNodes, Dictionary nodeDict) + private static List BuildComponentsLutAndApply(List sceneNodes, Dictionary nodeDict) { Dictionary lutIndex = new(); List lut = new(); @@ -193,4 +193,4 @@ public HierarchySerializationResult(List groups, HierarchyC this.Context = context; } } -} \ No newline at end of file +} diff --git a/Packages/src/Editor/FirstPartyTools/GetHierarchy/HierarchyAnalyzer/HierarchyService.cs b/Packages/src/Editor/FirstPartyTools/GetHierarchy/HierarchyAnalyzer/HierarchyService.cs index 8ca67f083..e28a4bf6f 100644 --- a/Packages/src/Editor/FirstPartyTools/GetHierarchy/HierarchyAnalyzer/HierarchyService.cs +++ b/Packages/src/Editor/FirstPartyTools/GetHierarchy/HierarchyAnalyzer/HierarchyService.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Globalization; using System.Linq; using UnityEngine; using UnityEngine.SceneManagement; @@ -334,7 +335,7 @@ private static GameObject[] GetDontDestroyOnLoadRootObjects() } } - private void TraverseHierarchy(GameObject obj, int? parentId, int depth, HierarchyOptions options, List nodes) + private void TraverseHierarchy(GameObject obj, string parentId, int depth, HierarchyOptions options, List nodes) { // Check depth limit if (options.MaxDepth >= 0 && depth > options.MaxDepth) @@ -352,8 +353,9 @@ private void TraverseHierarchy(GameObject obj, int? parentId, int depth, Hierarc } // Create node + string currentId = GetObjectId(obj); HierarchyNode node = new( - id: obj.GetInstanceID(), + id: currentId, name: obj.name, parent: parentId, depth: depth, @@ -368,7 +370,6 @@ private void TraverseHierarchy(GameObject obj, int? parentId, int depth, Hierarc nodes.Add(node); // Traverse children - int currentId = obj.GetInstanceID(); foreach (Transform child in obj.transform) { if (!options.IncludeInactive && !child.gameObject.activeInHierarchy) @@ -377,5 +378,17 @@ private void TraverseHierarchy(GameObject obj, int? parentId, int depth, Hierarc TraverseHierarchy(child.gameObject, currentId, depth + 1, options, nodes); } } + + private static string GetObjectId(UnityEngine.Object obj) + { + UnityEngine.Debug.Assert(obj != null, "Unity Object must exist before reading its identifier."); + +#if UNITY_6000_4_OR_NEWER + return obj.GetEntityId().ToString(); +#else + int instanceId = obj.GetInstanceID(); + return instanceId.ToString(CultureInfo.InvariantCulture); +#endif + } } } diff --git a/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs b/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs index c969c4fc7..4fb85cd3d 100644 --- a/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs +++ b/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs @@ -26,6 +26,7 @@ public sealed class UnityCliLoopServerControllerService : private readonly SessionRecoveryService _sessionRecoveryService; private readonly ServerReadinessStateStore _stateStore; private readonly IUnityCliLoopServerReadinessProbe _readinessProbe; + private readonly IUnityCliLoopServerDomainReloadLifecycle _domainReloadLifecycle; private IUnityCliLoopServerInstance _bridgeServer; private readonly SemaphoreSlim _startupSemaphore = new SemaphoreSlim(1, 1); private long _startupProtectionUntilTicks = 0; @@ -37,7 +38,8 @@ internal UnityCliLoopServerControllerService( IDomainReloadDetectionService domainReloadDetectionService, UnityCliLoopEditorSettingsService editorSettingsService, ServerReadinessStateStore stateStore, - IUnityCliLoopServerReadinessProbe readinessProbe) + IUnityCliLoopServerReadinessProbe readinessProbe, + IUnityCliLoopServerDomainReloadLifecycle domainReloadLifecycle) { System.Diagnostics.Debug.Assert(serverInstanceFactory != null, "serverInstanceFactory must not be null"); System.Diagnostics.Debug.Assert(serverLifecycleRegistry != null, "serverLifecycleRegistry must not be null"); @@ -45,6 +47,7 @@ internal UnityCliLoopServerControllerService( System.Diagnostics.Debug.Assert(editorSettingsService != null, "editorSettingsService must not be null"); System.Diagnostics.Debug.Assert(stateStore != null, "stateStore must not be null"); System.Diagnostics.Debug.Assert(readinessProbe != null, "readinessProbe must not be null"); + System.Diagnostics.Debug.Assert(domainReloadLifecycle != null, "domainReloadLifecycle must not be null"); _serverInstanceFactory = serverInstanceFactory ?? throw new ArgumentNullException(nameof(serverInstanceFactory)); _serverLifecycleRegistry = serverLifecycleRegistry ?? throw new ArgumentNullException(nameof(serverLifecycleRegistry)); @@ -52,6 +55,7 @@ internal UnityCliLoopServerControllerService( _editorSettingsService = editorSettingsService ?? throw new ArgumentNullException(nameof(editorSettingsService)); _stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore)); _readinessProbe = readinessProbe ?? throw new ArgumentNullException(nameof(readinessProbe)); + _domainReloadLifecycle = domainReloadLifecycle ?? throw new ArgumentNullException(nameof(domainReloadLifecycle)); _sessionRecoveryService = new SessionRecoveryService( this, _domainReloadDetectionService, @@ -289,6 +293,7 @@ internal async Task StopServerWithUseCaseAsync() internal void OnBeforeAssemblyReload() { ClearStartupProtection(); + _domainReloadLifecycle.PrepareForDomainReload(); string generationId = ServerReadinessStateStore.CreateGenerationId(); WriteServerState(ServerReadinessPhase.Reloading, generationId, "domain-reload-before", null); diff --git a/Packages/src/Runtime/uLoopMCP.Runtime.asmdef b/Packages/src/Runtime/uLoopMCP.Runtime.asmdef index 065693474..28b3d7590 100644 --- a/Packages/src/Runtime/uLoopMCP.Runtime.asmdef +++ b/Packages/src/Runtime/uLoopMCP.Runtime.asmdef @@ -2,7 +2,9 @@ "name": "uLoopMCP.Runtime", "rootNamespace": "io.github.hatayama.UnityCliLoop.Runtime", "references": [], - "includePlatforms": [], + "includePlatforms": [ + "Editor" + ], "excludePlatforms": [], "allowUnsafeCode": false, "overrideReferences": false,