From 81fa670faceb2c639e858590064441b126769ca5 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 9 Jul 2026 00:15:10 +0900 Subject: [PATCH] Extract native CLI setup command execution Move install and uninstall process execution into a focused runner so the installer remains responsible for orchestration and current-process state. Keep command behavior and timeout handling unchanged while routing existing tests directly through the extracted owner. --- .../Tests/Editor/NativeCliInstallerTests.cs | 12 +- .../Infrastructure/CLI/NativeCliInstaller.cs | 234 +---------------- .../CLI/NativeCliSetupCommandRunner.cs | 242 ++++++++++++++++++ .../CLI/NativeCliSetupCommandRunner.cs.meta | 11 + 4 files changed, 269 insertions(+), 230 deletions(-) create mode 100644 Packages/src/Editor/Infrastructure/CLI/NativeCliSetupCommandRunner.cs create mode 100644 Packages/src/Editor/Infrastructure/CLI/NativeCliSetupCommandRunner.cs.meta diff --git a/Assets/Tests/Editor/NativeCliInstallerTests.cs b/Assets/Tests/Editor/NativeCliInstallerTests.cs index 0b6dc4b85f..a889938f74 100644 --- a/Assets/Tests/Editor/NativeCliInstallerTests.cs +++ b/Assets/Tests/Editor/NativeCliInstallerTests.cs @@ -255,7 +255,10 @@ public void RunInstallCommand_WhenInstallerExecutableIsMissingReturnsFailure() "--version", "missing-uloop-release-installer --version"); - CliInstallResult result = NativeCliInstaller.RunInstallCommand(command, CancellationToken.None, 1000); + CliInstallResult result = NativeCliSetupCommandRunner.RunInstallCommand( + command, + CancellationToken.None, + 1000); Assert.That(result.Success, Is.False); Assert.That(result.ErrorOutput, Does.Contain("Failed to start release CLI installer")); @@ -267,7 +270,10 @@ public void RunInstallCommand_WhenInstallerDoesNotExitReturnsFailure() // Verifies that release installer stalls cannot leave the editor setup task alive forever. NativeCliInstallCommand command = BuildLongRunningInstallCommand(); - CliInstallResult result = NativeCliInstaller.RunInstallCommand(command, CancellationToken.None, 50); + CliInstallResult result = NativeCliSetupCommandRunner.RunInstallCommand( + command, + CancellationToken.None, + 50); Assert.That(result.Success, Is.False); Assert.That(result.ErrorOutput, Does.Contain("timed out")); @@ -281,7 +287,7 @@ public void RunUninstallCommand_WhenCanceledReportsUninstallCommand() using CancellationTokenSource cts = new(); cts.CancelAfter(10); - CliInstallResult result = NativeCliInstaller.RunUninstallCommand( + CliInstallResult result = NativeCliSetupCommandRunner.RunUninstallCommand( command, "/Users/ExampleUser/.local/bin", cts.Token, diff --git a/Packages/src/Editor/Infrastructure/CLI/NativeCliInstaller.cs b/Packages/src/Editor/Infrastructure/CLI/NativeCliInstaller.cs index 74a1fcf76c..de7817c696 100644 --- a/Packages/src/Editor/Infrastructure/CLI/NativeCliInstaller.cs +++ b/Packages/src/Editor/Infrastructure/CLI/NativeCliInstaller.cs @@ -1,7 +1,5 @@ using System; -using System.Diagnostics; using System.IO; -using System.Text; using System.Threading; using System.Threading.Tasks; using UnityEngine; @@ -18,7 +16,6 @@ namespace io.github.hatayama.UnityCliLoop.Infrastructure public static class NativeCliInstaller { private const int INSTALL_PROCESS_TIMEOUT_MS = 300000; - private const int INSTALL_PROCESS_WAIT_SLICE_MS = 250; public static NativeCliInstallCommand GetInstallCommand( RuntimePlatform platform, @@ -51,7 +48,7 @@ public static async Task InstallAsync( NativeCliInstallCommand command = GetInstallCommand(platform, cliReleaseTag, true); CliInstallResult result = await Task.Run( - () => RunInstallCommand(command, ct, INSTALL_PROCESS_TIMEOUT_MS), + () => NativeCliSetupCommandRunner.RunInstallCommand(command, ct, INSTALL_PROCESS_TIMEOUT_MS), ct); if (result.Success) @@ -82,7 +79,11 @@ public static async Task UninstallAsync(RuntimePlatform platfo installDirectory, platform); CliInstallResult result = await Task.Run( - () => RunUninstallCommand(command, installDirectory, ct, INSTALL_PROCESS_TIMEOUT_MS), + () => NativeCliSetupCommandRunner.RunUninstallCommand( + command, + installDirectory, + ct, + INSTALL_PROCESS_TIMEOUT_MS), ct); if (!result.Success) { @@ -96,7 +97,7 @@ public static async Task UninstallAsync(RuntimePlatform platfo platform, ct, NativeCliUninstallCompletionWaiter.UNINSTALL_COMPLETION_TIMEOUT_MS, - INSTALL_PROCESS_WAIT_SLICE_MS, + NativeCliSetupCommandRunner.INSTALL_PROCESS_WAIT_SLICE_MS, File.Exists, false, Environment.GetEnvironmentVariable, @@ -110,132 +111,6 @@ public static async Task UninstallAsync(RuntimePlatform platfo return result; } - internal static CliInstallResult RunInstallCommand( - NativeCliInstallCommand command, - CancellationToken ct, - int timeoutMs) - { - UnityEngine.Debug.Assert(!string.IsNullOrEmpty(command.FileName), "command.FileName must not be null or empty"); - UnityEngine.Debug.Assert(!string.IsNullOrEmpty(command.Arguments), "command.Arguments must not be null or empty"); - UnityEngine.Debug.Assert(timeoutMs > 0, "timeoutMs must be greater than zero"); - ct.ThrowIfCancellationRequested(); - - return RunCliSetupCommand( - command, - ct, - timeoutMs, - "release CLI installer", - startInfo => { }); - } - - internal static CliInstallResult RunUninstallCommand( - NativeCliInstallCommand command, - string installDirectory, - CancellationToken ct, - int timeoutMs) - { - UnityEngine.Debug.Assert(!string.IsNullOrEmpty(installDirectory), "installDirectory must not be null or empty"); - - return RunCliSetupCommand( - command, - ct, - timeoutMs, - "global CLI uninstall command", - startInfo => - { - startInfo.EnvironmentVariables[CliConstants.INSTALL_DIR_ENVIRONMENT_VARIABLE] = installDirectory; - }); - } - - private static CliInstallResult RunCliSetupCommand( - NativeCliInstallCommand command, - CancellationToken ct, - int timeoutMs, - string commandDescription, - Action configureStartInfo) - { - UnityEngine.Debug.Assert(!string.IsNullOrEmpty(command.FileName), "command.FileName must not be null or empty"); - UnityEngine.Debug.Assert(!string.IsNullOrEmpty(command.Arguments), "command.Arguments must not be null or empty"); - UnityEngine.Debug.Assert(timeoutMs > 0, "timeoutMs must be greater than zero"); - UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(commandDescription), "commandDescription must not be null or empty"); - UnityEngine.Debug.Assert(configureStartInfo != null, "configureStartInfo must not be null"); - ct.ThrowIfCancellationRequested(); - - ProcessStartInfo startInfo = new() - { - FileName = command.FileName, - Arguments = command.Arguments, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - configureStartInfo(startInfo); - - Process process = ProcessStartHelper.TryStart(startInfo); - if (process == null) - { - return new CliInstallResult( - false, - $"Failed to start {commandDescription}: {command.FileName}"); - } - - StringBuilder standardOutputBuilder = new(); - StringBuilder errorOutputBuilder = new(); - process.OutputDataReceived += (sender, e) => - { - if (e.Data != null) - { - standardOutputBuilder.AppendLine(e.Data); - } - }; - process.ErrorDataReceived += (sender, e) => - { - if (e.Data != null) - { - errorOutputBuilder.AppendLine(e.Data); - } - }; - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - bool canceled; - bool exited = WaitForInstallProcessExit(process, ct, timeoutMs, out canceled); - if (!exited) - { - KillProcessIfRunning(process); - process.WaitForExit(INSTALL_PROCESS_WAIT_SLICE_MS); - string timedOutStandardOutput = standardOutputBuilder.ToString(); - string timedOutErrorOutput = errorOutputBuilder.ToString(); - process.Dispose(); - - if (canceled) - { - return new CliInstallResult( - false, - $"{BuildSentenceSubject(commandDescription)} was canceled."); - } - - return new CliInstallResult( - false, - BuildCliSetupCommandTimeoutFailure( - commandDescription, - timeoutMs, - timedOutErrorOutput, - timedOutStandardOutput)); - } - - process.WaitForExit(); - string standardOutput = standardOutputBuilder.ToString(); - string errorOutput = errorOutputBuilder.ToString(); - bool success = process.ExitCode == 0; - process.Dispose(); - - return success - ? new CliInstallResult(true, standardOutput) - : new CliInstallResult(false, BuildCliSetupCommandFailure(commandDescription, errorOutput, standardOutput)); - } - internal static CliInstallResult FinishSuccessfulInstall( CliInstallResult installResult, string installDirectory, @@ -284,101 +159,6 @@ private static void RemoveInstallDirectoryFromCurrentProcessPath(RuntimePlatform Environment.SetEnvironmentVariable(pathVariableName, updatedPath); } - private static string BuildCliSetupCommandFailure( - string commandDescription, - string errorOutput, - string standardOutput) - { - UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(commandDescription), "commandDescription must not be null or empty"); - - if (!string.IsNullOrWhiteSpace(errorOutput)) - { - return errorOutput; - } - - if (!string.IsNullOrWhiteSpace(standardOutput)) - { - return standardOutput; - } - - return $"{BuildSentenceSubject(commandDescription)} failed without output."; - } - - private static string BuildCliSetupCommandTimeoutFailure( - string commandDescription, - int timeoutMs, - string errorOutput, - string standardOutput) - { - UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(commandDescription), "commandDescription must not be null or empty"); - - string capturedOutput = BuildCliSetupCommandFailure(commandDescription, errorOutput, standardOutput); - string noOutputMessage = $"{BuildSentenceSubject(commandDescription)} failed without output."; - if (string.Equals(capturedOutput, noOutputMessage, StringComparison.Ordinal)) - { - return $"{BuildSentenceSubject(commandDescription)} timed out after {timeoutMs} ms."; - } - - return $"{BuildSentenceSubject(commandDescription)} timed out after {timeoutMs} ms.\n{capturedOutput}"; - } - - private static string BuildSentenceSubject(string value) - { - UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(value), "value must not be null or empty"); - - return char.ToUpperInvariant(value[0]) + value.Substring(1); - } - - private static bool WaitForInstallProcessExit( - Process process, - CancellationToken ct, - int timeoutMs, - out bool canceled) - { - UnityEngine.Debug.Assert(process != null, "process must not be null"); - UnityEngine.Debug.Assert(timeoutMs > 0, "timeoutMs must be greater than zero"); - - canceled = false; - int remainingMs = timeoutMs; - while (remainingMs > 0) - { - if (ct.IsCancellationRequested) - { - canceled = true; - return false; - } - - int waitMs = Math.Min(INSTALL_PROCESS_WAIT_SLICE_MS, remainingMs); - if (process.WaitForExit(waitMs)) - { - return true; - } - - remainingMs -= waitMs; - } - - return false; - } - - private static void KillProcessIfRunning(Process process) - { - UnityEngine.Debug.Assert(process != null, "process must not be null"); - - try - { - if (process.HasExited) - { - return; - } - - process.Kill(); - } - catch (InvalidOperationException) - { - // Process exit can race with Kill, and timeout/cancel still needs to return a CliInstallResult. - } - } - } /// diff --git a/Packages/src/Editor/Infrastructure/CLI/NativeCliSetupCommandRunner.cs b/Packages/src/Editor/Infrastructure/CLI/NativeCliSetupCommandRunner.cs new file mode 100644 index 0000000000..cd3b6017c6 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/CLI/NativeCliSetupCommandRunner.cs @@ -0,0 +1,242 @@ +using System; +using System.Diagnostics; +using System.Text; +using System.Threading; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Runs native CLI install and uninstall processes. + /// + internal static class NativeCliSetupCommandRunner + { + internal const int INSTALL_PROCESS_WAIT_SLICE_MS = 250; + + internal static CliInstallResult RunInstallCommand( + NativeCliInstallCommand command, + CancellationToken ct, + int timeoutMs) + { + UnityEngine.Debug.Assert(!string.IsNullOrEmpty(command.FileName), "command.FileName must not be null or empty"); + UnityEngine.Debug.Assert(!string.IsNullOrEmpty(command.Arguments), "command.Arguments must not be null or empty"); + UnityEngine.Debug.Assert(timeoutMs > 0, "timeoutMs must be greater than zero"); + ct.ThrowIfCancellationRequested(); + + return RunCliSetupCommand( + command, + ct, + timeoutMs, + "release CLI installer", + startInfo => { }); + } + + internal static CliInstallResult RunUninstallCommand( + NativeCliInstallCommand command, + string installDirectory, + CancellationToken ct, + int timeoutMs) + { + UnityEngine.Debug.Assert(!string.IsNullOrEmpty(installDirectory), "installDirectory must not be null or empty"); + + return RunCliSetupCommand( + command, + ct, + timeoutMs, + "global CLI uninstall command", + startInfo => + { + startInfo.EnvironmentVariables[CliConstants.INSTALL_DIR_ENVIRONMENT_VARIABLE] = installDirectory; + }); + } + + private static CliInstallResult RunCliSetupCommand( + NativeCliInstallCommand command, + CancellationToken ct, + int timeoutMs, + string commandDescription, + Action configureStartInfo) + { + UnityEngine.Debug.Assert(!string.IsNullOrEmpty(command.FileName), "command.FileName must not be null or empty"); + UnityEngine.Debug.Assert(!string.IsNullOrEmpty(command.Arguments), "command.Arguments must not be null or empty"); + UnityEngine.Debug.Assert(timeoutMs > 0, "timeoutMs must be greater than zero"); + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(commandDescription), "commandDescription must not be null or empty"); + UnityEngine.Debug.Assert(configureStartInfo != null, "configureStartInfo must not be null"); + ct.ThrowIfCancellationRequested(); + + ProcessStartInfo startInfo = new() + { + FileName = command.FileName, + Arguments = command.Arguments, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + configureStartInfo(startInfo); + + Process process = ProcessStartHelper.TryStart(startInfo); + if (process == null) + { + return new CliInstallResult( + false, + $"Failed to start {commandDescription}: {command.FileName}"); + } + + StringBuilder standardOutputBuilder = new(); + StringBuilder errorOutputBuilder = new(); + process.OutputDataReceived += (sender, e) => + { + if (e.Data != null) + { + standardOutputBuilder.AppendLine(e.Data); + } + }; + process.ErrorDataReceived += (sender, e) => + { + if (e.Data != null) + { + errorOutputBuilder.AppendLine(e.Data); + } + }; + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + bool canceled; + bool exited = WaitForInstallProcessExit(process, ct, timeoutMs, out canceled); + if (!exited) + { + KillProcessIfRunning(process); + process.WaitForExit(INSTALL_PROCESS_WAIT_SLICE_MS); + string timedOutStandardOutput = standardOutputBuilder.ToString(); + string timedOutErrorOutput = errorOutputBuilder.ToString(); + process.Dispose(); + + if (canceled) + { + return new CliInstallResult( + false, + $"{BuildSentenceSubject(commandDescription)} was canceled."); + } + + return new CliInstallResult( + false, + BuildCliSetupCommandTimeoutFailure( + commandDescription, + timeoutMs, + timedOutErrorOutput, + timedOutStandardOutput)); + } + + process.WaitForExit(); + string standardOutput = standardOutputBuilder.ToString(); + string errorOutput = errorOutputBuilder.ToString(); + bool success = process.ExitCode == 0; + process.Dispose(); + + return success + ? new CliInstallResult(true, standardOutput) + : new CliInstallResult(false, BuildCliSetupCommandFailure(commandDescription, errorOutput, standardOutput)); + } + + private static string BuildCliSetupCommandFailure( + string commandDescription, + string errorOutput, + string standardOutput) + { + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(commandDescription), "commandDescription must not be null or empty"); + + if (!string.IsNullOrWhiteSpace(errorOutput)) + { + return errorOutput; + } + + if (!string.IsNullOrWhiteSpace(standardOutput)) + { + return standardOutput; + } + + return $"{BuildSentenceSubject(commandDescription)} failed without output."; + } + + private static string BuildCliSetupCommandTimeoutFailure( + string commandDescription, + int timeoutMs, + string errorOutput, + string standardOutput) + { + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(commandDescription), "commandDescription must not be null or empty"); + + string capturedOutput = BuildCliSetupCommandFailure(commandDescription, errorOutput, standardOutput); + string noOutputMessage = $"{BuildSentenceSubject(commandDescription)} failed without output."; + if (string.Equals(capturedOutput, noOutputMessage, StringComparison.Ordinal)) + { + return $"{BuildSentenceSubject(commandDescription)} timed out after {timeoutMs} ms."; + } + + return $"{BuildSentenceSubject(commandDescription)} timed out after {timeoutMs} ms.\n{capturedOutput}"; + } + + private static string BuildSentenceSubject(string value) + { + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(value), "value must not be null or empty"); + + return char.ToUpperInvariant(value[0]) + value.Substring(1); + } + + private static bool WaitForInstallProcessExit( + Process process, + CancellationToken ct, + int timeoutMs, + out bool canceled) + { + UnityEngine.Debug.Assert(process != null, "process must not be null"); + UnityEngine.Debug.Assert(timeoutMs > 0, "timeoutMs must be greater than zero"); + + canceled = false; + int remainingMs = timeoutMs; + while (remainingMs > 0) + { + if (ct.IsCancellationRequested) + { + canceled = true; + return false; + } + + int waitMs = Math.Min(INSTALL_PROCESS_WAIT_SLICE_MS, remainingMs); + if (process.WaitForExit(waitMs)) + { + return true; + } + + remainingMs -= waitMs; + } + + return false; + } + + private static void KillProcessIfRunning(Process process) + { + UnityEngine.Debug.Assert(process != null, "process must not be null"); + + try + { + if (process.HasExited) + { + return; + } + + process.Kill(); + } + catch (InvalidOperationException) + { + // Process exit can race with Kill, and timeout/cancel still needs to return a CliInstallResult. + } + } + } +} diff --git a/Packages/src/Editor/Infrastructure/CLI/NativeCliSetupCommandRunner.cs.meta b/Packages/src/Editor/Infrastructure/CLI/NativeCliSetupCommandRunner.cs.meta new file mode 100644 index 0000000000..6042840abf --- /dev/null +++ b/Packages/src/Editor/Infrastructure/CLI/NativeCliSetupCommandRunner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b4da499d28c294e2f8387d50281b7adf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: