2023-08-24 14:37:17 -04:00
// Copyright Epic Games, Inc. All Rights Reserved.
using System ;
using System.Collections.Generic ;
using System.Diagnostics ;
using System.IO ;
using System.Threading ;
2023-08-29 11:50:50 -04:00
using Amazon ;
2023-08-24 14:37:17 -04:00
using AutomationTool ;
using EpicGames.Core ;
2023-08-29 11:50:50 -04:00
using Microsoft.CodeAnalysis.CSharp.Syntax ;
using Microsoft.Extensions.Logging ;
2023-08-24 14:37:17 -04:00
using UnrealBuildBase ;
2023-08-29 11:50:50 -04:00
using UnrealBuildTool ;
2023-08-24 14:37:17 -04:00
namespace MultiClientLauncher.Automation
{
[Help("Run many game clients, a server, and connect them")]
[ParamHelp("ClientExe", "Absolute path to the client to run", ParamType = typeof(FileReference))]
[ParamHelp("ClientCount", "How many bot clients to run, must consecutively follow FirstClientNumber", ParamType = typeof(int))]
2023-08-29 11:50:50 -04:00
[ParamHelp("BuildIdOverride", "Parameter for -buildidoverride switch, often used to narrow down matchmaking to a particular server (optional)", ParamType = typeof(int))]
[ParamHelp("ClientArgsFile", "Absolute path to a file containing the client arguments (Engine/Build/AutomationWorkflows/ManyBotClientsDefault.txt by default)", ParamType = typeof(FileReference))]
[ParamHelp("ClientLogDir", "Absolute path to the directory with client log files (relative to the exe by default)", ParamType = typeof(FileReference))]
[ParamHelp("FirstClientNumber", "The number of the first LoadBot client (0 by default)", ParamType = typeof(int))]
2023-09-06 12:19:25 -04:00
[ParamHelp("NullRHI", "Pass -nullrhi to the clients, defaults to false", ParamType = typeof(bool))]
2023-08-29 11:50:50 -04:00
[ParamHelp("GridLayout", "If clients aren't nullrhi, lay them out in 320x240 fashion, defaults to true", ParamType = typeof(bool))]
2023-09-06 12:19:25 -04:00
[ParamHelp("NoTimeouts", "Disable timeouts in this script (defaults to false)", ParamType = typeof(bool))]
2023-08-29 11:50:50 -04:00
[ParamHelp("SleepTimeBetweenLaunches", "How long to sleep between running clients in milliseconds (could prevent race conditions), 100 by default", ParamType = typeof(int))]
2023-08-24 14:37:17 -04:00
[ParamHelp("MaxRunAttemptsPerClient", "Maximum number of attempts to run a client which crashes or fails to connect to the server, defaults to 3", ParamType = typeof(int))]
[ParamHelp("ClientSessionCompleted", "Log message indicating that a client has completed a game session and may be terminated", ParamType = typeof(string))]
[ParamHelp("ClientFailed", "Log message indicating that a client failed to connect to the server", ParamType = typeof(string))]
[ParamHelp("ClientConnected", "Log message indicating that a client connected to the server", ParamType = typeof(string))]
2023-10-19 10:42:42 -04:00
[ParamHelp("DeleteExistingLogs", "Whether to clear the log directory before launching clients, defaults to true", ParamType = typeof(bool))]
2023-08-24 14:37:17 -04:00
public class MultiClientLauncher : BuildCommand
{
private bool CancelClientProcesses = false ;
private string ClientExe ;
2023-08-29 11:50:50 -04:00
private string ClientLogDir ;
2023-08-24 14:37:17 -04:00
private string ClientArgs ;
2023-08-29 11:50:50 -04:00
private string ClientLogFilenameGuess ;
2023-08-24 14:37:17 -04:00
private int FirstClientNumber ;
private int ClientCount ;
2023-08-29 11:50:50 -04:00
private int BuildIdOverride ;
2023-09-06 12:19:25 -04:00
private bool NullRHI = false ;
2023-08-29 11:50:50 -04:00
private bool GridLayout = true ;
2023-09-06 12:19:25 -04:00
private bool NoTimeouts = false ;
2023-10-19 10:42:42 -04:00
private bool DeleteExistingLogs = true ;
2023-08-24 14:37:17 -04:00
2023-08-29 11:50:50 -04:00
private int SleepTimeBetweenLaunches = 100 ;
2023-08-24 14:37:17 -04:00
private int MaxRunAttemptsPerClient = 3 ;
private const int SleepTimeBetweenChecksForClientRelaunches = 1000 ;
protected ClientLogIndicators ClientIndicators ;
private void ParseCommandLine ( )
{
2023-08-29 11:50:50 -04:00
FileReference ClientExeFile = ParseRequiredFileReferenceParam ( "ClientExe" ) ;
ClientExe = ClientExeFile . ToString ( ) ;
2023-08-24 14:37:17 -04:00
ClientCount = int . Parse ( ParseRequiredStringParam ( "ClientCount" ) ) ;
2023-08-29 11:50:50 -04:00
BuildIdOverride = ParseParamInt ( "BuildIdOverride" , - 1 ) ;
// guess the log filename from the binary, e.g. FooClient-Linux-Shipping -> FooGame
ClientLogFilenameGuess = ClientExeFile . GetFileNameWithoutAnyExtensions ( ) ;
if ( ClientLogFilenameGuess . Contains ( "-" ) )
{
ClientLogFilenameGuess = ClientLogFilenameGuess . Split ( '-' ) [ 0 ] ;
}
if ( ClientLogFilenameGuess . Contains ( "Client" ) )
{
ClientLogFilenameGuess = ClientLogFilenameGuess . Replace ( "Client" , "Game" ) ;
}
// file comm sucks and is unreliable, but better than nothing
ClientLogDir = ParseParamValue ( "ClientLogDir" , "" ) ;
if ( string . IsNullOrEmpty ( ClientLogDir ) )
{
// figure out from ClientExe path
ClientLogDir = Utils . CollapseRelativeDirectories ( CommandUtils . CombinePaths ( ClientExeFile . Directory . ToString ( ) , "../../Saved/Logs" ) ) ;
}
FileReference ClientArgsFileRef = new FileReference ( ParseParamValue ( "ClientArgsFile" , GetDefaultArgsFile ( ) ) ) ;
if ( ! FileReference . Exists ( ClientArgsFileRef ) )
{
throw new BuildException ( "ClientArgs file {0} does not exist (override with -ClientArgsFile=...)" , ClientArgsFileRef ) ;
}
ClientArgs = FileReference . ReadAllText ( ClientArgsFileRef ) ;
if ( BuildIdOverride ! = - 1 )
{
ClientArgs + = string . Format ( " -buildidoverride={0} " , BuildIdOverride ) ;
}
2023-10-19 10:42:42 -04:00
FirstClientNumber = int . Parse ( ParseParamValue ( "FirstClientNumber" , "0" ) ) ;
2023-08-24 14:37:17 -04:00
SleepTimeBetweenLaunches = ParseParamInt ( "SleepTimeBetweenLaunches" , - 1 ) ;
MaxRunAttemptsPerClient = ParseParamInt ( "MaxRunAttemptsPerClient" , 3 ) ;
2023-09-06 12:19:25 -04:00
NullRHI = ParseParamBool ( "NullRHI" , NullRHI ) ;
GridLayout = ParseParamBool ( "GridLayout" , GridLayout ) ;
NoTimeouts = ParseParamBool ( "NoTimeouts" , NoTimeouts ) ;
2023-10-19 10:42:42 -04:00
DeleteExistingLogs = ParseParamBool ( "DeleteExistingLogs" , DeleteExistingLogs ) ;
2023-08-29 11:50:50 -04:00
// disable grid layout for nullrhi
2023-09-06 12:19:25 -04:00
if ( NullRHI | | ClientArgs . Contains ( "-nullrhi" ) )
2023-08-29 11:50:50 -04:00
{
GridLayout = false ;
}
2023-09-06 12:19:25 -04:00
if ( NullRHI )
{
ClientArgs + = " -nullrhi " ;
}
2023-08-24 14:37:17 -04:00
InitializeClientLogIndicators ( ) ;
}
2023-08-29 11:50:50 -04:00
protected virtual string GetDefaultArgsFile ( )
{
return "Engine/Build/AutomationWorkflows/ManyBotClientsDefault.txt" ;
}
2023-08-24 14:37:17 -04:00
// Derived commands may hardcode these log indicators
protected virtual void InitializeClientLogIndicators ( )
{
ClientIndicators . FinishedGameAndDisconnected = ParseRequiredStringParam ( "ClientSessionCompleted" ) ;
ClientIndicators . FailedToConnectToServer = ParseRequiredStringParam ( "ClientFailed" ) ;
ClientIndicators . ConnectedToServer = ParseRequiredStringParam ( "ClientConnected" ) ;
}
public override ExitCode Execute ( )
{
ParseCommandLine ( ) ;
List < ClientProcess > ClientProcesses = new List < ClientProcess > ( ) ;
// Allow ctrl C to terminate all client processes
Console . CancelKeyPress + = delegate
{
CancelClientProcesses = true ;
KillProcesses ( ClientProcesses ) ;
} ;
2023-08-29 11:50:50 -04:00
2023-10-19 10:42:42 -04:00
if ( DeleteExistingLogs )
2023-08-24 14:37:17 -04:00
{
2023-10-19 10:42:42 -04:00
// Delete all previous log files
Console . WriteLine ( "Deleting all existing log files in the log directory {0}" , ClientLogDir ) ;
string [ ] LogFiles = Directory . GetFiles ( ClientLogDir ) ;
foreach ( string Filename in LogFiles )
2023-08-24 14:37:17 -04:00
{
2023-10-19 10:42:42 -04:00
if ( Filename . EndsWith ( ".log" ) )
{
File . Delete ( Filename ) ;
}
2023-08-24 14:37:17 -04:00
}
}
2023-08-29 11:50:50 -04:00
try
2023-08-24 14:37:17 -04:00
{
2023-08-29 11:50:50 -04:00
// Run all client processes
Console . WriteLine ( "Spawning clients." ) ;
for ( int ClientIdx = 0 ; ClientIdx < ClientCount ; + + ClientIdx )
2023-08-24 14:37:17 -04:00
{
2023-08-29 11:50:50 -04:00
if ( CancelClientProcesses )
2023-08-24 14:37:17 -04:00
{
2023-08-29 11:50:50 -04:00
break ;
}
Console . WriteLine ( "Spawning client {0}..." , ClientIdx ) ;
2023-10-19 10:42:42 -04:00
string ClientNumber = ( FirstClientNumber + ClientIdx ) . ToString ( "00000.##" ) ;
string CurrentClientArgs = ClientArgs . Replace ( "#REPLACED_WITH_FIVE_DIGIT_CLIENT_ID#" , ClientNumber ) ;
2023-08-29 11:50:50 -04:00
string CurrentClientLog = CommandUtils . CombinePaths ( ClientLogDir , ClientLogFilenameGuess ) ;
if ( ClientIdx > 0 )
{
CurrentClientLog + = "_" + ( ClientIdx + 1 ) ;
}
CurrentClientLog + = ".log" ;
CurrentClientArgs + = string . Format ( " -abslog={0} " , CurrentClientLog ) ;
if ( GridLayout )
{
// assume 4k screen, which can fit 90 (10x9) clients running in 384x240. Note - not trying 320x240 as these days client will not use 4:3 aspect ratio
const int ResX = 384 ;
const int ResY = 240 ;
const int ClientsPerRow = 3840 / ResX ;
int WinY = ResY * ( ClientIdx / ClientsPerRow ) ;
int WinX = ResX * ( ClientIdx % ClientsPerRow ) ;
CurrentClientArgs + = string . Format ( " -WinX={0} -WinY={1} -ResX={2} -ResY={3} " , WinX , WinY , ResX , ResY ) ;
}
System . Console . WriteLine ( "Args: {0}" , CurrentClientArgs ) ;
ClientProcesses . Add ( SpawnClientProcess ( ClientExe , CurrentClientLog , CurrentClientArgs ) ) ;
if ( SleepTimeBetweenLaunches ! = - 1 )
{
Console . WriteLine ( "Sleeping for {0}ms..." , SleepTimeBetweenLaunches ) ;
Thread . Sleep ( SleepTimeBetweenLaunches ) ;
2023-08-24 14:37:17 -04:00
}
}
2023-08-29 11:50:50 -04:00
while ( ! CancelClientProcesses & & ClientProcesses . Count > 0 )
{
// Iterate through clients in reverse order for safe removal
for ( int i = ClientProcesses . Count - 1 ; i > = 0 ; i - - )
{
if ( ClientProcesses [ i ] . Stopped ( ) )
{
bool OutOfTries = ! ClientProcesses [ i ] . Start ( ) ;
if ( OutOfTries )
{
ClientProcesses . RemoveAt ( i ) ;
}
}
}
// Todo: Consider replacing sleeps with waiting for LogSkimmer threads to finish reading
// Todo: - That may still happen very fast and result in more busywaiting
Thread . Sleep ( SleepTimeBetweenChecksForClientRelaunches ) ;
}
return ExitCode . Success ;
}
catch ( Exception )
{
KillProcesses ( ClientProcesses ) ;
return ExitCode . Error_Unknown ;
}
2023-08-24 14:37:17 -04:00
}
private ClientProcess SpawnClientProcess ( string ExeFilename , string ClientLogFilename , string ExeArguments )
{
2023-09-06 12:19:25 -04:00
ClientProcess ClientProc = new ClientProcess ( ExeFilename , ExeArguments , MaxRunAttemptsPerClient , NoTimeouts , ClientLogFilename , ClientIndicators ) ;
2023-08-24 14:37:17 -04:00
ClientProc . Start ( ) ;
return ClientProc ;
}
private static void KillProcesses ( List < ClientProcess > Processes )
{
Console . WriteLine ( "Killing all client processes" ) ;
foreach ( ClientProcess CurrentProcess in Processes )
{
CurrentProcess . Kill ( ) ;
}
}
protected struct ClientLogIndicators
{
public string FinishedGameAndDisconnected ;
public string FailedToConnectToServer ;
public string ConnectedToServer ;
}
private class ClientProcess
{
private readonly Process Proc ;
private int RemainingRunAttempts ;
private Thread LogSkimmer ;
private const int SleepTimeBetweenLogSkims = 1000 ;
private readonly ClientLogIndicators LogIndicators ;
private readonly string LogFilepath ;
private readonly Stopwatch ConnectionTimer ;
2023-09-06 12:19:25 -04:00
private const int MinutesUntilTimeout = 15 ; // Todo: maybe make this a command line argument
private bool NoTimeouts = false ;
2023-08-24 14:37:17 -04:00
2023-09-06 12:19:25 -04:00
public ClientProcess ( string Exe , string Args , int MaxRunAttempts , bool IgnoreTimeouts , string ClientLog , ClientLogIndicators ClientIndicators )
2023-08-24 14:37:17 -04:00
{
Proc = new Process ( ) ;
Proc . StartInfo . FileName = Exe ;
Proc . StartInfo . Arguments = Args ;
RemainingRunAttempts = MaxRunAttempts ;
2023-09-06 12:19:25 -04:00
NoTimeouts = IgnoreTimeouts ;
2023-08-24 14:37:17 -04:00
LogFilepath = ClientLog ;
LogIndicators = ClientIndicators ;
ConnectionTimer = new Stopwatch ( ) ;
}
public bool Stopped ( )
{
return Proc . HasExited & & ! LogSkimmer . IsAlive ;
}
// Returns false if the client is out of attempts to start the process
public bool Start ( )
{
if ( RemainingRunAttempts < = 0 )
{
return false ;
}
RemainingRunAttempts - - ;
bool Success = Proc . Start ( ) ;
if ( ! Success )
{
Console . WriteLine ( "Failed to start process for {0}" , Proc . StartInfo . FileName ) ;
return true ;
}
ConnectionTimer . Restart ( ) ;
LogSkimmer = new Thread ( MonitorClient ) ;
LogSkimmer . Start ( ) ;
return true ;
}
private string GetProcessName ( )
{
return "(" + Proc . ProcessName + ": " + Proc . Id + ")" ;
}
public void Kill ( )
{
Proc . Kill ( ) ;
}
private void MonitorClient ( )
{
// Wait long enough for log files to be created
2023-08-29 11:50:50 -04:00
double MaxSecondsToWait = 10 ;
double WaitedSoFar = 0 ;
do
{
if ( File . Exists ( LogFilepath ) )
{
break ;
}
Thread . Sleep ( 2000 ) ;
WaitedSoFar + = 2 ;
if ( WaitedSoFar > = MaxSecondsToWait )
{
throw new BuildException ( "Log file {0} was not created after {1} seconds (did process crash on start?)" , LogFilepath , WaitedSoFar ) ;
}
}
while ( true ) ;
2023-08-24 14:37:17 -04:00
string AllServerOutput = "" ;
using FileStream ProcessLog = File . Open ( LogFilepath , FileMode . Open , FileAccess . Read , FileShare . ReadWrite ) ;
using StreamReader LogReader = new StreamReader ( ProcessLog ) ;
// Read until the process has exited or we found the success text in the log
while ( ! Proc . HasExited )
{
while ( ! LogReader . EndOfStream )
{
string Output = LogReader . ReadToEnd ( ) ;
if ( string . IsNullOrEmpty ( Output ) )
{
continue ;
}
AllServerOutput + = Output ;
if ( ConnectionTimer . IsRunning )
{
// If a client connected to the server, there's no more need to check for timing out
if ( AllServerOutput . Contains ( LogIndicators . ConnectedToServer ) )
{
Console . WriteLine ( "Client {0} successfully connected to the server." , GetProcessName ( ) ) ;
ConnectionTimer . Stop ( ) ;
break ;
}
// If client failed to connect, kill process and decrement attempts
if ( AllServerOutput . Contains ( LogIndicators . FailedToConnectToServer ) )
{
Console . WriteLine ( "Client {0} failed to connect to server. Relaunching client. Attempts left: {1}" ,
GetProcessName ( ) , RemainingRunAttempts ) ;
Kill ( ) ;
return ;
}
}
else
{
// If the client completed a session, kill the process
if ( AllServerOutput . Contains ( LogIndicators . FinishedGameAndDisconnected ) )
{
RemainingRunAttempts = 0 ;
Console . WriteLine ( "Client {0} completed session." , GetProcessName ( ) ) ;
Kill ( ) ;
return ;
}
}
}
// If timed out while attempting to connect, kill process and decrement attempts
2023-09-06 12:19:25 -04:00
if ( ! NoTimeouts & & ConnectionTimer . IsRunning & & ConnectionTimer . Elapsed . Minutes > = MinutesUntilTimeout )
2023-08-24 14:37:17 -04:00
{
Console . WriteLine ( "Client {0} timed out. Attempts left: {1}" , GetProcessName ( ) , RemainingRunAttempts ) ;
Kill ( ) ;
return ;
}
// Wait for more logging to occur
Thread . Sleep ( SleepTimeBetweenLogSkims ) ;
}
}
}
}
}