Files
UnrealEngine/Engine/Source/Programs/AutomationTool/Scripts/Bisect.Automation.cs
2025-05-18 13:04:45 +08:00

531 lines
17 KiB
C#

// Copyright Epic Games, Inc. All Rights Reserved.
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Logging;
using System.Linq;
using UnrealBuildTool;
using UnrealBuildBase;
namespace AutomationTool
{
[Help("Bisects a range of changelists between 'good' and 'bad' (exclusive). If a failed-build is reported, then bisection expands outwards from the active changelist until bisection resumed.")]
[Help("TargetStream=<Stream>", "Stream path. (Required)")]
[Help("RootPath=<Stream>", "Local root path (if not specified current client root path will be used (not recommended as some files used by the script might be replaced while syncing)). (Optional)")]
[Help("GoodCL=<CL>", "First changelist that is in good shape. (Required)")]
[Help("BadCL=<CL>", "Changelist that is known to be bad. (Required)")]
[Help("Project=<Project.uproject>", "Project file name to be synced. (Required)")]
[Help("TestsToCheck", "Test list to check the tests should be separated by semicolon. (Optional)")]
[RequireP4]
class Bisect : BuildCommand
{
/// <summary>
/// The class is a helper class for bisection method.
/// <para>
/// It stores a range of changelist indexes (between 'good' and 'bad' (exclusive)) to support bisection of the current range by calling <c>Bad</c> and <c>Good</c> methods.
/// The class also supports <c>Ugly</c> method to label current changelist index with 'failed to build' mark.
/// </para>
/// </summary>
private class Bisectomatron
{
public int GoodIndex { get; private set; }
public int BadIndex { get; private set; }
public int Index { get; private set; }
private int Jump;
private HashSet<int> UglyIndexes;
/// <summary>
/// The class constructor
/// </summary>
/// <param name="Length">Length of the desired range.</param>
public Bisectomatron(int Length)
{
GoodIndex = 0;
BadIndex = Length - 1;
Jump = -1;
UglyIndexes = new HashSet<int>();
Bisect();
}
/// <summary>
/// The method is designed to check whether the object of the class is still valid to proceed bisection.
/// </summary>
/// <param name="BisectomatronInstance">The object to check whether it is valid.</param>
public static implicit operator bool(Bisectomatron BisectomatronInstance)
{
return (BisectomatronInstance.BadIndex - BisectomatronInstance.GoodIndex) > (BisectomatronInstance.UglyIndexes.Count + 1);
}
/// <summary>
/// The method performs bisection of the current indexes range with taking into account indexes of changelists that were not built correctly.
/// </summary>
private void Bisect()
{
Index = (GoodIndex + BadIndex) / 2;
while (UglyIndexes.Contains(Index))
{
Index += Jump;
}
}
/// <summary>
/// The method handles current index as 'good' (build and test runs for the corresponding changelist were completed successfully) and proceed bisection if possible.
/// </summary>
public void Good()
{
UglyIndexes.RemoveWhere(UglyIndex => (UglyIndex <= Index));
GoodIndex = Index;
Jump = 1;
Bisect();
}
/// <summary>
/// The method handles current index as 'bad' (test run for the corresponding changelist was completed with error) and proceed bisection if possible.
/// </summary>
public void Bad()
{
UglyIndexes.RemoveWhere(UglyIndex => (UglyIndex >= Index));
BadIndex = Index;
Jump = -1;
Bisect();
}
/// <summary>
/// The method handles current index as 'ugly' (build of corresponding changelist was completed with error) and tries to determine the closest neighboring index that has not been checked yet.
/// <para>
/// While searching for the closest neighboring index that has not been checked yet the method goes in two directions.
/// It is performed by alternating the closest left hand side and right hand side neighbors that have not been handled yet.
/// </para>
/// </summary>
public void Ugly()
{
UglyIndexes.Add(Index);
// try to set the nearest neighbor index that is not in UglyIndexes.
while (this)
{
int CurrentJump = Jump;
Jump = -CurrentJump - (CurrentJump / Math.Abs(CurrentJump));
Index += CurrentJump;
if (!UglyIndexes.Contains(Index))
{
break;
}
}
}
}
/// <summary>
/// The class is a storage class for the project file information.
/// </summary>
private class ProjectFileInfo
{
public string DepotPath { get; set; }
public string FilePath { get; set; }
/// <summary>
/// Checks if the stored data is valid.
/// </summary>
/// <returns>True if the corresponding object stores valid data, otherwise - false.</returns>
public bool IsValid()
{
return
(!String.IsNullOrEmpty(DepotPath)) &&
(!String.IsNullOrEmpty(FilePath));
}
/// <summary>
/// The method returns depot path for the corresponding project file.
/// </summary>
/// <returns>Depot path for the corresponding project file or null if it can not be determined.</returns>
public string GetDepotDirPath()
{
if (String.IsNullOrEmpty(DepotPath))
{
return null;
}
string[] DepotPathParts = DepotPath.Split("/");
string[] DepotDirPathParts = DepotPathParts.Take(DepotPathParts.Length - 1).ToArray();
return String.Join("/", DepotDirPathParts);
}
}
private static readonly List<string> RequiredDirsToSync = new List<string>{ "Engine" };
private static readonly List<string> DirsToExcludeFromSync = new List<string> {
"Engine/Binaries/ThirdParty/DotNet",
"Engine/Source/Programs/AutomationTool"
};
private string TargetStream { get; set; }
private string RootPath { get; set; }
private string Project { get; set; }
private int GoodCL { get; set; }
private int BadCL { get; set; }
private string TestsToCheck { get; set; }
private List<string> GetRequiredDirsToSync()
{
List<string> Result = new List<string>();
foreach (string Dir in RequiredDirsToSync)
{
Result.Add(String.Format("{0}/{1}", TargetStream, Dir));
}
return Result;
}
private List<KeyValuePair<string, string>> GetExclusionViewParts()
{
List<KeyValuePair<string, string>> Result = new List<KeyValuePair<string, string>>();
// Apply exclusion list if and only if RootPath is not temporary (e.g it is local root)
if (RootPath == CommandUtils.CmdEnv.LocalRoot)
{
foreach (string Dir in DirsToExcludeFromSync)
{
Result.Add(new KeyValuePair<string, string>(
String.Format("-{0}/{1}/...", TargetStream, Dir),
String.Format("/{0}/...", Dir)));
}
}
return Result;
}
private void GetEntriesToSync(P4Connection Perforce, int CL, ProjectFileInfo ProjectFileInfo, out List<string> Dirs, out List<string> Files)
{
Dirs = GetRequiredDirsToSync();
string ProjectDirDepotPath = ProjectFileInfo.GetDepotDirPath();
if (!String.IsNullOrEmpty(ProjectDirDepotPath))
{
string[] ProjectDirDepotPathParts = ProjectDirDepotPath.Split("/");
if (!Dirs.Any(Dir => {
string[] DirPathParts = Dir.Split("/");
int PartsToCompareCount = Math.Min(DirPathParts.Length, ProjectDirDepotPathParts.Length);
bool bDirsHasTheSameDepotPathStart = Enumerable.SequenceEqual(ProjectDirDepotPathParts.Take(PartsToCompareCount), DirPathParts.Take(PartsToCompareCount));
return bDirsHasTheSameDepotPathStart;
}))
{
Dirs.Add(ProjectDirDepotPath);
}
}
string FStatOutput;
// Note that the command line containt '*' symbol. Because of that all the additional parameters after fstat will be placed into a temorary file.
// Every parameter in this temporary file should be in a separate line (using of '\n' instead of ' ').
string FStatCommandLine = String.Format("fstat -Olhp\n-Dl\n-F\n^headAction=delete & ^headAction=move/delete\n{0}/*@{1}", TargetStream, CL);
if (!Perforce.LogP4Output(out FStatOutput, "", FStatCommandLine))
{
throw new AutomationException(String.Format("p4 {0} failed", FStatCommandLine));
}
Files = new List<string>();
FStatOutput = FStatOutput.Replace("\r", "");
string[] OutputLines = FStatOutput.Split("\n");
string FilePrefix = new string("... depotFile ");
foreach (string OutputLine in OutputLines)
{
string TrimmedOutputLine = OutputLine.Trim();
if (TrimmedOutputLine.StartsWith(FilePrefix))
{
Files.Add(TrimmedOutputLine.Substring(FilePrefix.Length));
}
}
}
private ProjectFileInfo GetProjectFileInfo(P4Connection Perforce)
{
if (String.IsNullOrEmpty(TargetStream) || String.IsNullOrEmpty(Project))
{
return null;
}
string FStatOutput;
string FStatCommandLine = String.Format("fstat -Olhp -Dl -F \"^headAction=delete & ^headAction=move/delete\" {0}/.../{1}", TargetStream, Project);
Perforce.LogP4Output(out FStatOutput, "", FStatCommandLine);
FStatOutput = FStatOutput.Replace("\r", "");
string[] OutputLines = FStatOutput.Split("\n");
string DepotFilePathPrefix = new string("... depotFile ");
string FilePathPrefix = new string("... path ");
ProjectFileInfo Result = new ProjectFileInfo();
foreach (string OutputLine in OutputLines)
{
string TrimmedOutputLine = OutputLine.Trim();
if (TrimmedOutputLine.EndsWith(Project))
{
if (TrimmedOutputLine.StartsWith(DepotFilePathPrefix))
{
Result.DepotPath = TrimmedOutputLine.Substring(DepotFilePathPrefix.Length);
}
else if (TrimmedOutputLine.StartsWith(FilePathPrefix))
{
Result.FilePath = TrimmedOutputLine.Substring(FilePathPrefix.Length);
}
if (Result.IsValid())
{
return Result;
}
}
}
return null;
}
private bool RunExecutableInRootEnvironment(string ExecutablePath, string CommandLineArgs)
{
string DirectoryToRestore = Environment.CurrentDirectory;
if (!CommandUtils.ChDir_NoExceptions(RootPath))
{
return false;
}
Dictionary<string, string> OverridenEnv = new Dictionary<string, string>();
OverridenEnv.Add("uebp_FinalLogFolder", CommandUtils.CombinePaths(RootPath, "Engine/Programs/AutomationTool/Saved/Logs"));
OverridenEnv.Add("uebp_EngineSavedFolder", CommandUtils.CombinePaths(RootPath, "Engine/Programs/AutomationTool/Saved"));
OverridenEnv.Add("uebp_LOCAL_ROOT", RootPath);
OverridenEnv.Add("uebp_LogFolder", CommandUtils.CombinePaths(RootPath, "Engine/Programs/AutomationTool/Saved/Logs"));
IProcessResult ProcessResult;
try
{
ProcessResult = CommandUtils.Run(ExecutablePath, CommandLineArgs, null, ERunOptions.Default, OverridenEnv, null, "Bisect.BuildProject", RootPath);
}
catch
{
return false;
}
finally
{
CommandUtils.ChDir_NoExceptions(DirectoryToRestore);
}
return ProcessResult.ExitCode == 0;
}
private bool BuildProject(ProjectFileInfo ProjectFileInfo)
{
string CommandLineArgs = String.Format("BuildCookRun -project={0} -platform={1} -build", ProjectFileInfo.FilePath, HostPlatform.Current.HostEditorPlatform.ToString());
bool bResult = true;
if (RootPath == CommandUtils.CmdEnv.LocalRoot)
{
try
{
CommandUtils.RunUAT(CommandUtils.CmdEnv, CommandLineArgs, "BisectBuild");
}
catch
{
bResult = false;
}
}
else
{
string ExecutablePath = CommandUtils.CombinePaths(RootPath, "RunUAT");
if (HostPlatform.Current.HostEditorPlatform == UnrealTargetPlatform.Win64)
{
ExecutablePath += ".bat";
}
else
{
ExecutablePath += ".sh";
}
bResult = RunExecutableInRootEnvironment(ExecutablePath, CommandLineArgs);
}
return bResult;
}
private bool RunTests()
{
string ExecutablePath = CommandUtils.CombinePaths(RootPath, "Engine/Binaries", HostPlatform.Current.HostEditorPlatform.ToString(), "UnrealEditor");
if (HostPlatform.Current.HostEditorPlatform == UnrealTargetPlatform.Win64)
{
ExecutablePath += ".exe";
}
string CommandLineArgs = String.Format(" {0} -ExecCmds=\"Automation RunTests {1};Quit;\"", Project, TestsToCheck);
return RunExecutableInRootEnvironment(ExecutablePath,CommandLineArgs);
}
private void ParseCommandLineArgs()
{
TargetStream = ParseParamValue("TargetStream");
if (TargetStream == null)
{
throw new AutomationException("TargetStream is not specified");
}
TargetStream = TargetStream.TrimEnd(new char[] { '/', '\\' });
if (TargetStream.Length == 0)
{
throw new AutomationException("TargetStream is empty");
}
RootPath = ParseParamValue("RootPath");
if (RootPath == null)
{
RootPath = CommandUtils.CmdEnv.LocalRoot;
}
else
{
RootPath = RootPath.TrimEnd(new char[] { '/', '\\' });
if (RootPath.Length == 0)
{
RootPath = CommandUtils.CmdEnv.LocalRoot;
}
}
Project = ParseParamValue("Project");
if (String.IsNullOrEmpty(Project))
{
throw new AutomationException("Project is not specified");
}
GoodCL = ParseParamInt("GoodCL");
BadCL = ParseParamInt("BadCL");
if (GoodCL >= BadCL)
{
throw new AutomationException("Invalid range of changelists given (GoodCL >= BadCL)");
}
TestsToCheck = ParseParamValue("TestsToCheck");
}
/// <summary>
/// Executes the command
/// </summary>
public override void ExecuteBuild()
{
ParseCommandLineArgs();
string PerforceClientName = String.Format("{0}_{1}_Automation_Bisect_Temp", P4Env.User, Unreal.MachineName);
bool bPerforceClientCreatedOutside = P4.DoesClientExist(PerforceClientName);
if (!bPerforceClientCreatedOutside)
{
List<KeyValuePair<string, string>> RequiredView = new List<KeyValuePair<string, string>>();
RequiredView.Add(new KeyValuePair<string, string>(
String.Format("{0}/...", TargetStream),
String.Format("/...", PerforceClientName)));
RequiredView.AddRange(GetExclusionViewParts());
P4ClientInfo PerforceClientInfo = new P4ClientInfo();
PerforceClientInfo.Owner = P4Env.User;
PerforceClientInfo.Host = Unreal.MachineName;
PerforceClientInfo.RootPath = RootPath;
PerforceClientInfo.Name = PerforceClientName;
PerforceClientInfo.View = RequiredView;
PerforceClientInfo.Stream = null;
PerforceClientInfo.Options = P4ClientOption.NoAllWrite | P4ClientOption.Clobber | P4ClientOption.NoCompress | P4ClientOption.Unlocked | P4ClientOption.NoModTime | P4ClientOption.RmDir;
PerforceClientInfo.LineEnd = P4LineEnd.Local;
P4.CreateClient(PerforceClientInfo);
}
// Sync the workspace and delete the client
try
{
P4Connection Perforce = new P4Connection(P4Env.User, PerforceClientName);
// Build p4 command line. '-L' enables changelist descriptions, '-l' enables full descriptions, '-s submitted' ignores shelves and pending changes
string ChangesCommandLine = String.Format("-L -l -s submitted {0}/...@{1},{2}", TargetStream, GoodCL, BadCL);
Perforce.Changes(out List<P4Connection.ChangeRecord> SubmittedCandidateChanges, ChangesCommandLine);
if (SubmittedCandidateChanges.Count < 3)
{
throw new AutomationException("Perforce returned too few changelists between {0} and {1}", GoodCL, BadCL);
}
Bisectomatron IndexGenerator = new Bisectomatron(SubmittedCandidateChanges.Count);
while (IndexGenerator)
{
int Index = IndexGenerator.Index;
P4Connection.ChangeRecord ChangeRecord = SubmittedCandidateChanges[Index];
int WorkingCL = ChangeRecord.CL;
ProjectFileInfo ProjectFileInfo = GetProjectFileInfo(Perforce);
if ((ProjectFileInfo == null) || (!ProjectFileInfo.IsValid()))
{
throw new AutomationException("Can not determine project info for project name \"{0}\" and CL {1}", Project, WorkingCL);
}
List<string> DirsToSync;
List<string> FilesToSync;
GetEntriesToSync(Perforce, WorkingCL, ProjectFileInfo, out DirsToSync, out FilesToSync);
string SyncCommandLine = new string("");
if (DirsToSync.Any())
{
Logger.LogInformation("Dirs to sync:");
foreach (string DirToSync in DirsToSync)
{
SyncCommandLine += String.Format(" {0}/...@{1}", DirToSync, WorkingCL);
Logger.LogInformation(" {0}", DirToSync);
}
}
if (FilesToSync.Any())
{
Logger.LogInformation("Files to sync:");
foreach (string FileToSync in FilesToSync)
{
SyncCommandLine += String.Format(" {0}@{1}", FileToSync, WorkingCL);
Logger.LogInformation(" {0}", FileToSync);
}
}
if (String.IsNullOrEmpty(SyncCommandLine))
{
// There is nothing to sync
throw new AutomationException("There is nothing to sync");
}
Logger.LogInformation("Syncing to {SyncCL}...", WorkingCL);
Perforce.Sync(SyncCommandLine);
Logger.LogInformation("Syncing to {SyncCL} has been finished.", WorkingCL);
if (!BuildProject(ProjectFileInfo))
{
IndexGenerator.Ugly();
continue;
}
if (!String.IsNullOrEmpty(TestsToCheck))
{
if (RunTests())
{
IndexGenerator.Good();
}
else
{
IndexGenerator.Bad();
}
}
}
Logger.LogInformation("Bisection complete.");
Logger.LogInformation("Good change: {0}", SubmittedCandidateChanges[IndexGenerator.GoodIndex].CL);
Logger.LogInformation("Bad change: {0}", SubmittedCandidateChanges[IndexGenerator.BadIndex].CL);
}
catch
{
throw;
}
finally
{
if (!bPerforceClientCreatedOutside)
{
P4.DeleteClient(PerforceClientName);
}
}
}
}
}