From 048ebe69b8e6e929a20486c413b86dc61323358b Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 8 Jul 2026 23:49:16 +0900 Subject: [PATCH] Extract native CLI uninstall completion waiting Move uninstall target and User PATH polling into a focused waiter while preserving timeout, cancellation, and message behavior. Keep polling cadence supplied by the installer so process wait timing can be separated next. --- .../Tests/Editor/NativeCliInstallerTests.cs | 16 +- .../Infrastructure/CLI/NativeCliInstaller.cs | 131 +--------------- .../CLI/NativeCliUninstallCompletionWaiter.cs | 144 ++++++++++++++++++ ...NativeCliUninstallCompletionWaiter.cs.meta | 11 ++ 4 files changed, 166 insertions(+), 136 deletions(-) create mode 100644 Packages/src/Editor/Infrastructure/CLI/NativeCliUninstallCompletionWaiter.cs create mode 100644 Packages/src/Editor/Infrastructure/CLI/NativeCliUninstallCompletionWaiter.cs.meta diff --git a/Assets/Tests/Editor/NativeCliInstallerTests.cs b/Assets/Tests/Editor/NativeCliInstallerTests.cs index 1189c378f4..0b6dc4b85f 100644 --- a/Assets/Tests/Editor/NativeCliInstallerTests.cs +++ b/Assets/Tests/Editor/NativeCliInstallerTests.cs @@ -298,7 +298,7 @@ public async Task WaitForUninstallTargetRemovalAsync_WhenDeferredRemovalComplete int remainingExistingChecks = 2; int delayCount = 0; - CliInstallResult result = await NativeCliInstaller.WaitForUninstallTargetRemovalAsync( + CliInstallResult result = await NativeCliUninstallCompletionWaiter.WaitForUninstallTargetRemovalAsync( "C:\\Users\\ExampleUser\\AppData\\Local\\Programs\\uloop\\bin\\uloop.exe", CancellationToken.None, 1000, @@ -320,7 +320,7 @@ public async Task WaitForUninstallTargetRemovalAsync_WhenTargetRemainsReturnsFai // Verifies that delayed launcher removal failures do not recache a stale installed CLI. int delayCount = 0; - CliInstallResult result = await NativeCliInstaller.WaitForUninstallTargetRemovalAsync( + CliInstallResult result = await NativeCliUninstallCompletionWaiter.WaitForUninstallTargetRemovalAsync( "C:\\Users\\ExampleUser\\AppData\\Local\\Programs\\uloop\\bin\\uloop.exe", CancellationToken.None, 250, @@ -345,7 +345,7 @@ public async Task WaitForUninstallCompletionAsync_OnWindowsWaitsForUserPathRemov string userPath = installDirectory + ";C:\\npm"; int delayCount = 0; - CliInstallResult result = await NativeCliInstaller.WaitForUninstallCompletionAsync( + CliInstallResult result = await NativeCliUninstallCompletionWaiter.WaitForUninstallCompletionAsync( installDirectory + "\\uloop.exe", installDirectory, RuntimePlatform.WindowsEditor, @@ -373,7 +373,7 @@ public async Task WaitForUninstallCompletionAsync_OnWindowsFailsWhenUserPathRema string installDirectory = "C:\\Users\\ExampleUser\\Programs\\uloop\\bin"; int delayCount = 0; - CliInstallResult result = await NativeCliInstaller.WaitForUninstallCompletionAsync( + CliInstallResult result = await NativeCliUninstallCompletionWaiter.WaitForUninstallCompletionAsync( installDirectory + "\\uloop.exe", installDirectory, RuntimePlatform.WindowsEditor, @@ -401,7 +401,7 @@ public async Task WaitForUninstallCompletionAsync_WhenUserPathRemovalIsNotRequir // Verifies fallback uninstall failures do not claim ownership of Windows User PATH cleanup. string installDirectory = "C:\\Users\\ExampleUser\\Programs\\uloop\\bin"; - CliInstallResult result = await NativeCliInstaller.WaitForUninstallCompletionAsync( + CliInstallResult result = await NativeCliUninstallCompletionWaiter.WaitForUninstallCompletionAsync( installDirectory + "\\uloop.exe", installDirectory, RuntimePlatform.WindowsEditor, @@ -424,7 +424,7 @@ public async Task WaitForUninstallCompletionAsync_WhenUserPathRemovalIsNotRequir // Verifies fallback launchers can complete uninstall without owning Windows User PATH cleanup. string installDirectory = "C:\\Users\\ExampleUser\\Programs\\uloop\\bin"; - CliInstallResult result = await NativeCliInstaller.WaitForUninstallCompletionAsync( + CliInstallResult result = await NativeCliUninstallCompletionWaiter.WaitForUninstallCompletionAsync( installDirectory + "\\uloop.exe", installDirectory, RuntimePlatform.WindowsEditor, @@ -443,7 +443,9 @@ public async Task WaitForUninstallCompletionAsync_WhenUserPathRemovalIsNotRequir public void UninstallCompletionTimeout_IsLongEnoughForDeferredWindowsPowerShellCleanup() { // Verifies Settings uninstall does not report failure before Windows deferred cleanup can finish. - Assert.That(NativeCliInstaller.UNINSTALL_COMPLETION_TIMEOUT_MS, Is.GreaterThanOrEqualTo(30000)); + Assert.That( + NativeCliUninstallCompletionWaiter.UNINSTALL_COMPLETION_TIMEOUT_MS, + Is.GreaterThanOrEqualTo(30000)); } [Test] diff --git a/Packages/src/Editor/Infrastructure/CLI/NativeCliInstaller.cs b/Packages/src/Editor/Infrastructure/CLI/NativeCliInstaller.cs index 1fafdf5400..74a1fcf76c 100644 --- a/Packages/src/Editor/Infrastructure/CLI/NativeCliInstaller.cs +++ b/Packages/src/Editor/Infrastructure/CLI/NativeCliInstaller.cs @@ -19,7 +19,6 @@ public static class NativeCliInstaller { private const int INSTALL_PROCESS_TIMEOUT_MS = 300000; private const int INSTALL_PROCESS_WAIT_SLICE_MS = 250; - internal const int UNINSTALL_COMPLETION_TIMEOUT_MS = 30000; public static NativeCliInstallCommand GetInstallCommand( RuntimePlatform platform, @@ -91,12 +90,12 @@ public static async Task UninstallAsync(RuntimePlatform platfo } string installPath = NativeCliInstallPathResolver.GetGlobalCliInstallPath(installDirectory, platform); - CliInstallResult removalResult = await WaitForUninstallCompletionAsync( + CliInstallResult removalResult = await NativeCliUninstallCompletionWaiter.WaitForUninstallCompletionAsync( installPath, installDirectory, platform, ct, - UNINSTALL_COMPLETION_TIMEOUT_MS, + NativeCliUninstallCompletionWaiter.UNINSTALL_COMPLETION_TIMEOUT_MS, INSTALL_PROCESS_WAIT_SLICE_MS, File.Exists, false, @@ -237,92 +236,6 @@ private static CliInstallResult RunCliSetupCommand( : new CliInstallResult(false, BuildCliSetupCommandFailure(commandDescription, errorOutput, standardOutput)); } - internal static async Task WaitForUninstallTargetRemovalAsync( - string targetPath, - CancellationToken ct, - int timeoutMs, - int pollMs, - Func fileExists, - Func delayAsync) - { - UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(targetPath), "targetPath must not be null or empty"); - UnityEngine.Debug.Assert(timeoutMs > 0, "timeoutMs must be greater than zero"); - UnityEngine.Debug.Assert(pollMs > 0, "pollMs must be greater than zero"); - UnityEngine.Debug.Assert(fileExists != null, "fileExists must not be null"); - UnityEngine.Debug.Assert(delayAsync != null, "delayAsync must not be null"); - - int elapsedMs = 0; - while (fileExists(targetPath)) - { - ct.ThrowIfCancellationRequested(); - if (elapsedMs >= timeoutMs) - { - return new CliInstallResult( - false, - $"Timed out waiting for uLoop CLI uninstall to remove {targetPath}."); - } - - int delayMs = Math.Min(pollMs, timeoutMs - elapsedMs); - await delayAsync(delayMs, ct); - elapsedMs += delayMs; - } - - return new CliInstallResult(true, ""); - } - - internal static async Task WaitForUninstallCompletionAsync( - string targetPath, - string installDirectory, - RuntimePlatform platform, - CancellationToken ct, - int timeoutMs, - int pollMs, - Func fileExists, - bool requireUserPathRemoval, - Func getEnvironmentVariable, - Func delayAsync) - { - UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(targetPath), "targetPath must not be null or empty"); - UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(installDirectory), "installDirectory must not be null or empty"); - UnityEngine.Debug.Assert(timeoutMs > 0, "timeoutMs must be greater than zero"); - UnityEngine.Debug.Assert(pollMs > 0, "pollMs must be greater than zero"); - UnityEngine.Debug.Assert(fileExists != null, "fileExists must not be null"); - UnityEngine.Debug.Assert(getEnvironmentVariable != null, "getEnvironmentVariable must not be null"); - UnityEngine.Debug.Assert(delayAsync != null, "delayAsync must not be null"); - - int elapsedMs = 0; - while (true) - { - bool targetStillExists = fileExists(targetPath); - bool userPathStillContainsInstallDirectory = ShouldWaitForUserPathRemoval( - requireUserPathRemoval, - installDirectory, - platform, - getEnvironmentVariable); - if (!targetStillExists && !userPathStillContainsInstallDirectory) - { - return new CliInstallResult(true, ""); - } - - ct.ThrowIfCancellationRequested(); - if (elapsedMs >= timeoutMs) - { - return new CliInstallResult( - false, - BuildUninstallCompletionTimeoutFailure( - targetPath, - installDirectory, - platform, - targetStillExists, - userPathStillContainsInstallDirectory)); - } - - int delayMs = Math.Min(pollMs, timeoutMs - elapsedMs); - await delayAsync(delayMs, ct); - elapsedMs += delayMs; - } - } - internal static CliInstallResult FinishSuccessfulInstall( CliInstallResult installResult, string installDirectory, @@ -371,46 +284,6 @@ private static void RemoveInstallDirectoryFromCurrentProcessPath(RuntimePlatform Environment.SetEnvironmentVariable(pathVariableName, updatedPath); } - private static bool ShouldWaitForUserPathRemoval( - bool requireUserPathRemoval, - string installDirectory, - RuntimePlatform platform, - Func getEnvironmentVariable) - { - UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(installDirectory), "installDirectory must not be null or empty"); - UnityEngine.Debug.Assert(getEnvironmentVariable != null, "getEnvironmentVariable must not be null"); - - return requireUserPathRemoval - && NativeCliInstallPathResolver.DoesUserPathContainInstallDirectory( - installDirectory, - platform, - getEnvironmentVariable); - } - - private static string BuildUninstallCompletionTimeoutFailure( - string targetPath, - string installDirectory, - RuntimePlatform platform, - bool targetStillExists, - bool userPathStillContainsInstallDirectory) - { - UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(targetPath), "targetPath must not be null or empty"); - UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(installDirectory), "installDirectory must not be null or empty"); - UnityEngine.Debug.Assert(targetStillExists || userPathStillContainsInstallDirectory, "at least one uninstall cleanup condition must still be pending"); - - if (platform != RuntimePlatform.WindowsEditor || !userPathStillContainsInstallDirectory) - { - return $"Timed out waiting for uLoop CLI uninstall to remove {targetPath}."; - } - - if (!targetStillExists) - { - return $"Timed out waiting for uLoop CLI uninstall to remove {installDirectory} from Windows User PATH."; - } - - return $"Timed out waiting for uLoop CLI uninstall to remove {targetPath} and remove {installDirectory} from Windows User PATH."; - } - private static string BuildCliSetupCommandFailure( string commandDescription, string errorOutput, diff --git a/Packages/src/Editor/Infrastructure/CLI/NativeCliUninstallCompletionWaiter.cs b/Packages/src/Editor/Infrastructure/CLI/NativeCliUninstallCompletionWaiter.cs new file mode 100644 index 0000000000..f5cbd73ac1 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/CLI/NativeCliUninstallCompletionWaiter.cs @@ -0,0 +1,144 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.Application; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Waits for native CLI uninstall cleanup to finish. + /// + internal static class NativeCliUninstallCompletionWaiter + { + internal const int UNINSTALL_COMPLETION_TIMEOUT_MS = 30000; + + internal static async Task WaitForUninstallTargetRemovalAsync( + string targetPath, + CancellationToken ct, + int timeoutMs, + int pollMs, + Func fileExists, + Func delayAsync) + { + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(targetPath), "targetPath must not be null or empty"); + UnityEngine.Debug.Assert(timeoutMs > 0, "timeoutMs must be greater than zero"); + UnityEngine.Debug.Assert(pollMs > 0, "pollMs must be greater than zero"); + UnityEngine.Debug.Assert(fileExists != null, "fileExists must not be null"); + UnityEngine.Debug.Assert(delayAsync != null, "delayAsync must not be null"); + + int elapsedMs = 0; + while (fileExists(targetPath)) + { + ct.ThrowIfCancellationRequested(); + if (elapsedMs >= timeoutMs) + { + return new CliInstallResult( + false, + $"Timed out waiting for uLoop CLI uninstall to remove {targetPath}."); + } + + int delayMs = Math.Min(pollMs, timeoutMs - elapsedMs); + await delayAsync(delayMs, ct); + elapsedMs += delayMs; + } + + return new CliInstallResult(true, ""); + } + + internal static async Task WaitForUninstallCompletionAsync( + string targetPath, + string installDirectory, + RuntimePlatform platform, + CancellationToken ct, + int timeoutMs, + int pollMs, + Func fileExists, + bool requireUserPathRemoval, + Func getEnvironmentVariable, + Func delayAsync) + { + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(targetPath), "targetPath must not be null or empty"); + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(installDirectory), "installDirectory must not be null or empty"); + UnityEngine.Debug.Assert(timeoutMs > 0, "timeoutMs must be greater than zero"); + UnityEngine.Debug.Assert(pollMs > 0, "pollMs must be greater than zero"); + UnityEngine.Debug.Assert(fileExists != null, "fileExists must not be null"); + UnityEngine.Debug.Assert(getEnvironmentVariable != null, "getEnvironmentVariable must not be null"); + UnityEngine.Debug.Assert(delayAsync != null, "delayAsync must not be null"); + + int elapsedMs = 0; + while (true) + { + bool targetStillExists = fileExists(targetPath); + bool userPathStillContainsInstallDirectory = ShouldWaitForUserPathRemoval( + requireUserPathRemoval, + installDirectory, + platform, + getEnvironmentVariable); + if (!targetStillExists && !userPathStillContainsInstallDirectory) + { + return new CliInstallResult(true, ""); + } + + ct.ThrowIfCancellationRequested(); + if (elapsedMs >= timeoutMs) + { + return new CliInstallResult( + false, + BuildUninstallCompletionTimeoutFailure( + targetPath, + installDirectory, + platform, + targetStillExists, + userPathStillContainsInstallDirectory)); + } + + int delayMs = Math.Min(pollMs, timeoutMs - elapsedMs); + await delayAsync(delayMs, ct); + elapsedMs += delayMs; + } + } + + private static bool ShouldWaitForUserPathRemoval( + bool requireUserPathRemoval, + string installDirectory, + RuntimePlatform platform, + Func getEnvironmentVariable) + { + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(installDirectory), "installDirectory must not be null or empty"); + UnityEngine.Debug.Assert(getEnvironmentVariable != null, "getEnvironmentVariable must not be null"); + + return requireUserPathRemoval + && NativeCliInstallPathResolver.DoesUserPathContainInstallDirectory( + installDirectory, + platform, + getEnvironmentVariable); + } + + private static string BuildUninstallCompletionTimeoutFailure( + string targetPath, + string installDirectory, + RuntimePlatform platform, + bool targetStillExists, + bool userPathStillContainsInstallDirectory) + { + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(targetPath), "targetPath must not be null or empty"); + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(installDirectory), "installDirectory must not be null or empty"); + UnityEngine.Debug.Assert(targetStillExists || userPathStillContainsInstallDirectory, "at least one uninstall cleanup condition must still be pending"); + + if (platform != RuntimePlatform.WindowsEditor || !userPathStillContainsInstallDirectory) + { + return $"Timed out waiting for uLoop CLI uninstall to remove {targetPath}."; + } + + if (!targetStillExists) + { + return $"Timed out waiting for uLoop CLI uninstall to remove {installDirectory} from Windows User PATH."; + } + + return $"Timed out waiting for uLoop CLI uninstall to remove {targetPath} and remove {installDirectory} from Windows User PATH."; + } + } +} diff --git a/Packages/src/Editor/Infrastructure/CLI/NativeCliUninstallCompletionWaiter.cs.meta b/Packages/src/Editor/Infrastructure/CLI/NativeCliUninstallCompletionWaiter.cs.meta new file mode 100644 index 0000000000..9f324a4966 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/CLI/NativeCliUninstallCompletionWaiter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b9dc372d657654ed8b3579d930469685 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: