diff --git a/src/DependencyManagement/DependencyManager.cs b/src/DependencyManagement/DependencyManager.cs index 112ed48f..8210e89d 100644 --- a/src/DependencyManagement/DependencyManager.cs +++ b/src/DependencyManagement/DependencyManager.cs @@ -79,7 +79,8 @@ internal DependencyManager() /// /// The protobuf messaging stream /// The StreamingMessage request for function load - internal void ProcessDependencyDownload(MessagingStream msgStream, StreamingMessage request) + /// The PowerShell instance used to download modules + internal void ProcessDependencyDownload(MessagingStream msgStream, StreamingMessage request, PowerShell pwsh) { if (request.FunctionLoadRequest.ManagedDependencyEnabled) { @@ -100,7 +101,7 @@ internal void ProcessDependencyDownload(MessagingStream msgStream, StreamingMess } //Start dependency download on a separate thread - _dependencyDownloadTask = Task.Run(() => ProcessDependencies(rpcLogger)); + _dependencyDownloadTask = Task.Run(() => InstallFunctionAppDependencies(pwsh, rpcLogger)); } } @@ -117,22 +118,6 @@ internal void WaitOnDependencyDownload() } } - private void ProcessDependencies(RpcLogger rpcLogger) - { - try - { - _dependencyError = null; - using (PowerShell pwsh = PowerShell.Create(Utils.SingletonISS.Value)) - { - InstallFunctionAppDependencies(pwsh, rpcLogger); - } - } - catch (Exception e) - { - _dependencyError = e; - } - } - /// /// Initializes the dependency manger and performs the following: /// - Parse functionAppRoot\requirements.psd1 file and create a list of dependencies to install. @@ -251,7 +236,7 @@ internal void InstallFunctionAppDependencies(PowerShell pwsh, ILogger logger) catch (Exception e) { var errorMsg = string.Format(PowerShellWorkerStrings.FailToInstallFuncAppDependencies, e.Message); - throw new DependencyInstallationException(errorMsg, e); + _dependencyError = new DependencyInstallationException(errorMsg, e); } } diff --git a/src/PowerShell/PowerShellManager.cs b/src/PowerShell/PowerShellManager.cs index d753ffa2..19ec6f19 100644 --- a/src/PowerShell/PowerShellManager.cs +++ b/src/PowerShell/PowerShellManager.cs @@ -22,6 +22,7 @@ internal class PowerShellManager { private readonly ILogger _logger; private readonly PowerShell _pwsh; + private bool _runspaceInited; /// /// Gets the Runspace InstanceId. @@ -45,27 +46,64 @@ static PowerShellManager() addMethod.Invoke(null, new object[] { "HttpRequestContext", typeof(HttpRequestContext) }); } - internal PowerShellManager(ILogger logger) + /// + /// Constructor for setting the basic fields. + /// + private PowerShellManager(ILogger logger, PowerShell pwsh, int id) + { + _logger = logger; + _pwsh = pwsh; + _pwsh.Runspace.Name = $"PowerShellManager{id}"; + } + + /// + /// Create a PowerShellManager instance but defer the Initialization. + /// + /// + /// This constructor is only for creating the very first PowerShellManager instance. + /// The initialization work is deferred until all prerequisites are ready, such as + /// the dependent modules are downloaded and all Az functions are loaded. + /// + internal PowerShellManager(ILogger logger, PowerShell pwsh) + : this(logger, pwsh, id: 1) { - if (FunctionLoader.FunctionAppRootPath == null) + } + + /// + /// Create a PowerShellManager instance and initialize it. + /// + internal PowerShellManager(ILogger logger, int id) + : this(logger, Utils.NewPwshInstance(), id) + { + // Initialize the Runspace + Initialize(); + } + + /// + /// Extra initialization of the Runspace. + /// + internal void Initialize() + { + if (!_runspaceInited) { - throw new InvalidOperationException(PowerShellWorkerStrings.FunctionAppRootNotResolved); + RegisterStreamEvents(); + InvokeProfile(FunctionLoader.FunctionAppProfilePath); + _runspaceInited = true; } + } - _logger = logger; - _pwsh = PowerShell.Create(Utils.SingletonISS.Value); - - // Setup Stream event listeners - var streamHandler = new StreamHandler(logger); + /// + /// Setup Stream event listeners. + /// + private void RegisterStreamEvents() + { + var streamHandler = new StreamHandler(_logger); _pwsh.Streams.Debug.DataAdding += streamHandler.DebugDataAdding; _pwsh.Streams.Error.DataAdding += streamHandler.ErrorDataAdding; _pwsh.Streams.Information.DataAdding += streamHandler.InformationDataAdding; _pwsh.Streams.Progress.DataAdding += streamHandler.ProgressDataAdding; _pwsh.Streams.Verbose.DataAdding += streamHandler.VerboseDataAdding; _pwsh.Streams.Warning.DataAdding += streamHandler.WarningDataAdding; - - // Initialize the Runspace - InvokeProfile(FunctionLoader.FunctionAppProfilePath); } /// @@ -76,7 +114,8 @@ internal void InvokeProfile(string profilePath) Exception exception = null; if (profilePath == null) { - RpcLogger.WriteSystemLog(string.Format(PowerShellWorkerStrings.FileNotFound, "profile.ps1", FunctionLoader.FunctionAppRootPath)); + string noProfileMsg = string.Format(PowerShellWorkerStrings.FileNotFound, "profile.ps1", FunctionLoader.FunctionAppRootPath); + _logger.Log(LogLevel.Trace, noProfileMsg); return; } diff --git a/src/PowerShell/PowerShellManagerPool.cs b/src/PowerShell/PowerShellManagerPool.cs index f5226434..d2fb13ab 100644 --- a/src/PowerShell/PowerShellManagerPool.cs +++ b/src/PowerShell/PowerShellManagerPool.cs @@ -46,6 +46,18 @@ internal PowerShellManagerPool(MessagingStream msgStream) RpcLogger.WriteSystemLog(string.Format(PowerShellWorkerStrings.LogConcurrencyUpperBound, _upperBound.ToString())); } + /// + /// Populate the pool with the very first PowerShellManager instance. + /// We instantiate PowerShellManager instances in a lazy way, starting from size 1 and increase the number of workers as needed. + /// + internal void Initialize(PowerShell pwsh) + { + var logger = new RpcLogger(_msgStream); + var psManager = new PowerShellManager(logger, pwsh); + _pool.Add(psManager); + _poolSize = 1; + } + /// /// Checkout an idle PowerShellManager instance in a non-blocking asynchronous way. /// @@ -59,18 +71,22 @@ internal PowerShellManager CheckoutIdleWorker(StreamingMessage request, AzFuncti if (!_pool.TryTake(out psManager)) { // The pool doesn't have an idle one. - if (_poolSize < _upperBound && - Interlocked.Increment(ref _poolSize) <= _upperBound) + if (_poolSize < _upperBound) { - // If the pool hasn't reached its bounded capacity yet, then - // we create a new item and return it. - var logger = new RpcLogger(_msgStream); - logger.SetContext(requestId, invocationId); - psManager = new PowerShellManager(logger); + int id = Interlocked.Increment(ref _poolSize); + if (id <= _upperBound) + { + // If the pool hasn't reached its bounded capacity yet, then + // we create a new item and return it. + var logger = new RpcLogger(_msgStream); + logger.SetContext(requestId, invocationId); + psManager = new PowerShellManager(logger, id); - RpcLogger.WriteSystemLog(string.Format(PowerShellWorkerStrings.LogNewPowerShellManagerCreated, _poolSize.ToString())); + RpcLogger.WriteSystemLog(string.Format(PowerShellWorkerStrings.LogNewPowerShellManagerCreated, id.ToString())); + } } - else + + if (psManager == null) { // If the pool has reached its bounded capacity, then the thread // should be blocked until an idle one becomes available. @@ -78,9 +94,14 @@ internal PowerShellManager CheckoutIdleWorker(StreamingMessage request, AzFuncti } } + // Finish the initialization if not yet. + // This applies only to the very first PowerShellManager instance, whose initialization was deferred. + psManager.Initialize(); + // Register the function with the Runspace before returning the idle PowerShellManager. FunctionMetadata.RegisterFunctionMetadata(psManager.InstanceId, functionInfo); psManager.Logger.SetContext(requestId, invocationId); + return psManager; } diff --git a/src/RequestProcessor.cs b/src/RequestProcessor.cs index 26959ad8..f0d37245 100644 --- a/src/RequestProcessor.cs +++ b/src/RequestProcessor.cs @@ -170,7 +170,15 @@ internal StreamingMessage ProcessFunctionLoadRequest(StreamingMessage request) // Setup the FunctionApp root path and module path. FunctionLoader.SetupWellKnownPaths(functionLoadRequest); - _dependencyManager.ProcessDependencyDownload(_msgStream, request); + + // Create the very first Runspace so the debugger has the target to attach to. + // This PowerShell instance is shared by the first PowerShellManager instance created in the pool, + // and the dependency manager (used to download dependent modules if needed). + var pwsh = Utils.NewPwshInstance(); + _powershellPool.Initialize(pwsh); + + // Start the download asynchronously if needed. + _dependencyManager.ProcessDependencyDownload(_msgStream, request, pwsh); } catch (Exception e) { diff --git a/src/Utility/Utils.cs b/src/Utility/Utils.cs index 5b94bc0b..349bbe5d 100644 --- a/src/Utility/Utils.cs +++ b/src/Utility/Utils.cs @@ -5,14 +5,15 @@ using System; using System.IO; -using System.Management.Automation; -using System.Management.Automation.Runspaces; using System.Text; using System.Threading; using Microsoft.PowerShell.Commands; namespace Microsoft.Azure.Functions.PowerShellWorker.Utility { + using System.Management.Automation; + using System.Management.Automation.Runspaces; + internal class Utils { internal readonly static CmdletInfo ImportModuleCmdletInfo = new CmdletInfo("Import-Module", typeof(ImportModuleCommand)); @@ -20,28 +21,38 @@ internal class Utils internal readonly static CmdletInfo GetJobCmdletInfo = new CmdletInfo("Get-Job", typeof(GetJobCommand)); internal readonly static CmdletInfo RemoveJobCmdletInfo = new CmdletInfo("Remove-Job", typeof(RemoveJobCommand)); - internal readonly static Lazy SingletonISS - = new Lazy(NewInitialSessionState, LazyThreadSafetyMode.PublicationOnly); + private static InitialSessionState s_iss; - private static InitialSessionState NewInitialSessionState() + /// + /// Create a new PowerShell instance using our singleton InitialSessionState instance. + /// + internal static PowerShell NewPwshInstance() { - var iss = InitialSessionState.CreateDefault(); - iss.ThreadOptions = PSThreadOptions.UseCurrentThread; - iss.EnvironmentVariables.Add( - new SessionStateVariableEntry( - "PSModulePath", - FunctionLoader.FunctionModulePath, - description: null)); - - // Setting the execution policy on macOS and Linux throws an exception so only update it on Windows - if(Platform.IsWindows) + if (s_iss == null) { - // This sets the execution policy on Windows to Unrestricted which is required to run the user's function scripts on - // Windows client versions. This is needed if a user is testing their function locally with the func CLI. - iss.ExecutionPolicy = Microsoft.PowerShell.ExecutionPolicy.Unrestricted; + if (FunctionLoader.FunctionAppRootPath == null) + { + throw new InvalidOperationException(PowerShellWorkerStrings.FunctionAppRootNotResolved); + } + + s_iss = InitialSessionState.CreateDefault(); + s_iss.ThreadOptions = PSThreadOptions.UseCurrentThread; + s_iss.EnvironmentVariables.Add( + new SessionStateVariableEntry( + "PSModulePath", + FunctionLoader.FunctionModulePath, + description: null)); + + // Setting the execution policy on macOS and Linux throws an exception so only update it on Windows + if(Platform.IsWindows) + { + // This sets the execution policy on Windows to Unrestricted which is required to run the user's function scripts on + // Windows client versions. This is needed if a user is testing their function locally with the func CLI. + s_iss.ExecutionPolicy = Microsoft.PowerShell.ExecutionPolicy.Unrestricted; + } } - return iss; + return PowerShell.Create(s_iss); } /// diff --git a/test/Unit/PowerShell/PowerShellManagerTests.cs b/test/Unit/PowerShell/PowerShellManagerTests.cs index 26741784..75e5a3fb 100644 --- a/test/Unit/PowerShell/PowerShellManagerTests.cs +++ b/test/Unit/PowerShell/PowerShellManagerTests.cs @@ -7,13 +7,15 @@ using System.Collections; using System.Collections.Generic; using System.IO; -using System.Management.Automation; using Microsoft.Azure.Functions.PowerShellWorker.PowerShell; +using Microsoft.Azure.Functions.PowerShellWorker.Utility; using Microsoft.Azure.WebJobs.Script.Grpc.Messages; using Xunit; namespace Microsoft.Azure.Functions.PowerShellWorker.Test { + using System.Management.Automation; + internal class TestUtils { internal const string TestInputBindingName = "req"; @@ -43,9 +45,9 @@ static TestUtils() // Have a single place to get a PowerShellManager for testing. // This is to guarantee that the well known paths are setup before calling the constructor of PowerShellManager. - internal static PowerShellManager NewTestPowerShellManager(ConsoleLogger logger) + internal static PowerShellManager NewTestPowerShellManager(ConsoleLogger logger, PowerShell pwsh = null) { - return new PowerShellManager(logger); + return pwsh != null ? new PowerShellManager(logger, pwsh) : new PowerShellManager(logger, id: 2); } internal static AzFunctionInfo NewAzFunctionInfo(string scriptFile, string entryPoint) @@ -235,5 +237,26 @@ public void ProfileWithNonTerminatingError() Assert.Equal("Error: ERROR: help me!", _testLogger.FullLog[0]); Assert.Matches("Error: Fail to run profile.ps1. See logs for detailed errors. Profile location: ", _testLogger.FullLog[1]); } + + [Fact] + public void PSManagerCtorRunsProfileByDefault() + { + //initialize fresh log + _testLogger.FullLog.Clear(); + TestUtils.NewTestPowerShellManager(_testLogger); + + Assert.Single(_testLogger.FullLog); + Assert.Equal($"Trace: No 'profile.ps1' is found at the FunctionApp root folder: {FunctionLoader.FunctionAppRootPath}.", _testLogger.FullLog[0]); + } + + [Fact] + public void PSManagerCtorDoesNotRunProfileIfDelayInit() + { + //initialize fresh log + _testLogger.FullLog.Clear(); + TestUtils.NewTestPowerShellManager(_testLogger, Utils.NewPwshInstance()); + + Assert.Empty(_testLogger.FullLog); + } } }