Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions Assets/Tests/Editor/NativeCliInstallerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand All @@ -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"));
Expand All @@ -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,
Expand Down
234 changes: 7 additions & 227 deletions Packages/src/Editor/Infrastructure/CLI/NativeCliInstaller.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
Expand All @@ -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,
Expand Down Expand Up @@ -51,7 +48,7 @@ public static async Task<CliInstallResult> 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)
Expand Down Expand Up @@ -82,7 +79,11 @@ public static async Task<CliInstallResult> 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)
{
Expand All @@ -96,7 +97,7 @@ public static async Task<CliInstallResult> 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,
Expand All @@ -110,132 +111,6 @@ public static async Task<CliInstallResult> 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<ProcessStartInfo> 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,
Expand Down Expand Up @@ -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.
}
}

}

/// <summary>
Expand Down
Loading
Loading