Skip to content

Commit 3ed9960

Browse files
authored
Create only one Runspace instance at the FunctionLoad stage (#201)
This is to fix the regression in local debugging. We create the first `PowerShellManager` instance when processing the first `FunctionLoad` request, so the debugger has the target Runspace to attach to. However, we delay the initialization of the first `PowerShellManager` instance until the first invocation (when checking out the instance from pool). It turns out we cannot have the dependency manager to create a separate Runspace for downloading. The VS Code local debugging assumes the target `Runspace` has `Id = 1`, which was true before as only one Runspace will be created initially. Now, the dependency manager creates a separate `Runspace` for downloading, and there will be a race condition -- sometimes the `Runspace` created for the downloading has `Id = 1`, and thus the debugger will attach to that Runspace, which will be reclaimed after the download and causing the `Wait-Debugger` in the user's function script to not be hit. This refactoring makes sure only one `Runspace` is created initially -- the `PowerShell` instance used for the downloading is also used to serve the first invocation request. So there is no race condition and local debugging can work reliably.
1 parent 45322e8 commit 3ed9960

File tree

6 files changed

+150
-63
lines changed

6 files changed

+150
-63
lines changed

src/DependencyManagement/DependencyManager.cs

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ internal DependencyManager()
7979
/// </summary>
8080
/// <param name="msgStream">The protobuf messaging stream</param>
8181
/// <param name="request">The StreamingMessage request for function load</param>
82-
internal void ProcessDependencyDownload(MessagingStream msgStream, StreamingMessage request)
82+
/// <param name="pwsh">The PowerShell instance used to download modules</param>
83+
internal void ProcessDependencyDownload(MessagingStream msgStream, StreamingMessage request, PowerShell pwsh)
8384
{
8485
if (request.FunctionLoadRequest.ManagedDependencyEnabled)
8586
{
@@ -100,7 +101,7 @@ internal void ProcessDependencyDownload(MessagingStream msgStream, StreamingMess
100101
}
101102

102103
//Start dependency download on a separate thread
103-
_dependencyDownloadTask = Task.Run(() => ProcessDependencies(rpcLogger));
104+
_dependencyDownloadTask = Task.Run(() => InstallFunctionAppDependencies(pwsh, rpcLogger));
104105
}
105106
}
106107

@@ -117,22 +118,6 @@ internal void WaitOnDependencyDownload()
117118
}
118119
}
119120

120-
private void ProcessDependencies(RpcLogger rpcLogger)
121-
{
122-
try
123-
{
124-
_dependencyError = null;
125-
using (PowerShell pwsh = PowerShell.Create(Utils.SingletonISS.Value))
126-
{
127-
InstallFunctionAppDependencies(pwsh, rpcLogger);
128-
}
129-
}
130-
catch (Exception e)
131-
{
132-
_dependencyError = e;
133-
}
134-
}
135-
136121
/// <summary>
137122
/// Initializes the dependency manger and performs the following:
138123
/// - Parse functionAppRoot\requirements.psd1 file and create a list of dependencies to install.
@@ -251,7 +236,7 @@ internal void InstallFunctionAppDependencies(PowerShell pwsh, ILogger logger)
251236
catch (Exception e)
252237
{
253238
var errorMsg = string.Format(PowerShellWorkerStrings.FailToInstallFuncAppDependencies, e.Message);
254-
throw new DependencyInstallationException(errorMsg, e);
239+
_dependencyError = new DependencyInstallationException(errorMsg, e);
255240
}
256241
}
257242

src/PowerShell/PowerShellManager.cs

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ internal class PowerShellManager
2222
{
2323
private readonly ILogger _logger;
2424
private readonly PowerShell _pwsh;
25+
private bool _runspaceInited;
2526

2627
/// <summary>
2728
/// Gets the Runspace InstanceId.
@@ -45,27 +46,64 @@ static PowerShellManager()
4546
addMethod.Invoke(null, new object[] { "HttpRequestContext", typeof(HttpRequestContext) });
4647
}
4748

48-
internal PowerShellManager(ILogger logger)
49+
/// <summary>
50+
/// Constructor for setting the basic fields.
51+
/// </summary>
52+
private PowerShellManager(ILogger logger, PowerShell pwsh, int id)
53+
{
54+
_logger = logger;
55+
_pwsh = pwsh;
56+
_pwsh.Runspace.Name = $"PowerShellManager{id}";
57+
}
58+
59+
/// <summary>
60+
/// Create a PowerShellManager instance but defer the Initialization.
61+
/// </summary>
62+
/// <remarks>
63+
/// This constructor is only for creating the very first PowerShellManager instance.
64+
/// The initialization work is deferred until all prerequisites are ready, such as
65+
/// the dependent modules are downloaded and all Az functions are loaded.
66+
/// </remarks>
67+
internal PowerShellManager(ILogger logger, PowerShell pwsh)
68+
: this(logger, pwsh, id: 1)
4969
{
50-
if (FunctionLoader.FunctionAppRootPath == null)
70+
}
71+
72+
/// <summary>
73+
/// Create a PowerShellManager instance and initialize it.
74+
/// </summary>
75+
internal PowerShellManager(ILogger logger, int id)
76+
: this(logger, Utils.NewPwshInstance(), id)
77+
{
78+
// Initialize the Runspace
79+
Initialize();
80+
}
81+
82+
/// <summary>
83+
/// Extra initialization of the Runspace.
84+
/// </summary>
85+
internal void Initialize()
86+
{
87+
if (!_runspaceInited)
5188
{
52-
throw new InvalidOperationException(PowerShellWorkerStrings.FunctionAppRootNotResolved);
89+
RegisterStreamEvents();
90+
InvokeProfile(FunctionLoader.FunctionAppProfilePath);
91+
_runspaceInited = true;
5392
}
93+
}
5494

55-
_logger = logger;
56-
_pwsh = PowerShell.Create(Utils.SingletonISS.Value);
57-
58-
// Setup Stream event listeners
59-
var streamHandler = new StreamHandler(logger);
95+
/// <summary>
96+
/// Setup Stream event listeners.
97+
/// </summary>
98+
private void RegisterStreamEvents()
99+
{
100+
var streamHandler = new StreamHandler(_logger);
60101
_pwsh.Streams.Debug.DataAdding += streamHandler.DebugDataAdding;
61102
_pwsh.Streams.Error.DataAdding += streamHandler.ErrorDataAdding;
62103
_pwsh.Streams.Information.DataAdding += streamHandler.InformationDataAdding;
63104
_pwsh.Streams.Progress.DataAdding += streamHandler.ProgressDataAdding;
64105
_pwsh.Streams.Verbose.DataAdding += streamHandler.VerboseDataAdding;
65106
_pwsh.Streams.Warning.DataAdding += streamHandler.WarningDataAdding;
66-
67-
// Initialize the Runspace
68-
InvokeProfile(FunctionLoader.FunctionAppProfilePath);
69107
}
70108

71109
/// <summary>
@@ -76,7 +114,8 @@ internal void InvokeProfile(string profilePath)
76114
Exception exception = null;
77115
if (profilePath == null)
78116
{
79-
RpcLogger.WriteSystemLog(string.Format(PowerShellWorkerStrings.FileNotFound, "profile.ps1", FunctionLoader.FunctionAppRootPath));
117+
string noProfileMsg = string.Format(PowerShellWorkerStrings.FileNotFound, "profile.ps1", FunctionLoader.FunctionAppRootPath);
118+
_logger.Log(LogLevel.Trace, noProfileMsg);
80119
return;
81120
}
82121

src/PowerShell/PowerShellManagerPool.cs

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ internal PowerShellManagerPool(MessagingStream msgStream)
4646
RpcLogger.WriteSystemLog(string.Format(PowerShellWorkerStrings.LogConcurrencyUpperBound, _upperBound.ToString()));
4747
}
4848

49+
/// <summary>
50+
/// Populate the pool with the very first PowerShellManager instance.
51+
/// We instantiate PowerShellManager instances in a lazy way, starting from size 1 and increase the number of workers as needed.
52+
/// </summary>
53+
internal void Initialize(PowerShell pwsh)
54+
{
55+
var logger = new RpcLogger(_msgStream);
56+
var psManager = new PowerShellManager(logger, pwsh);
57+
_pool.Add(psManager);
58+
_poolSize = 1;
59+
}
60+
4961
/// <summary>
5062
/// Checkout an idle PowerShellManager instance in a non-blocking asynchronous way.
5163
/// </summary>
@@ -59,28 +71,37 @@ internal PowerShellManager CheckoutIdleWorker(StreamingMessage request, AzFuncti
5971
if (!_pool.TryTake(out psManager))
6072
{
6173
// The pool doesn't have an idle one.
62-
if (_poolSize < _upperBound &&
63-
Interlocked.Increment(ref _poolSize) <= _upperBound)
74+
if (_poolSize < _upperBound)
6475
{
65-
// If the pool hasn't reached its bounded capacity yet, then
66-
// we create a new item and return it.
67-
var logger = new RpcLogger(_msgStream);
68-
logger.SetContext(requestId, invocationId);
69-
psManager = new PowerShellManager(logger);
76+
int id = Interlocked.Increment(ref _poolSize);
77+
if (id <= _upperBound)
78+
{
79+
// If the pool hasn't reached its bounded capacity yet, then
80+
// we create a new item and return it.
81+
var logger = new RpcLogger(_msgStream);
82+
logger.SetContext(requestId, invocationId);
83+
psManager = new PowerShellManager(logger, id);
7084

71-
RpcLogger.WriteSystemLog(string.Format(PowerShellWorkerStrings.LogNewPowerShellManagerCreated, _poolSize.ToString()));
85+
RpcLogger.WriteSystemLog(string.Format(PowerShellWorkerStrings.LogNewPowerShellManagerCreated, id.ToString()));
86+
}
7287
}
73-
else
88+
89+
if (psManager == null)
7490
{
7591
// If the pool has reached its bounded capacity, then the thread
7692
// should be blocked until an idle one becomes available.
7793
psManager = _pool.Take();
7894
}
7995
}
8096

97+
// Finish the initialization if not yet.
98+
// This applies only to the very first PowerShellManager instance, whose initialization was deferred.
99+
psManager.Initialize();
100+
81101
// Register the function with the Runspace before returning the idle PowerShellManager.
82102
FunctionMetadata.RegisterFunctionMetadata(psManager.InstanceId, functionInfo);
83103
psManager.Logger.SetContext(requestId, invocationId);
104+
84105
return psManager;
85106
}
86107

src/RequestProcessor.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,15 @@ internal StreamingMessage ProcessFunctionLoadRequest(StreamingMessage request)
170170

171171
// Setup the FunctionApp root path and module path.
172172
FunctionLoader.SetupWellKnownPaths(functionLoadRequest);
173-
_dependencyManager.ProcessDependencyDownload(_msgStream, request);
173+
174+
// Create the very first Runspace so the debugger has the target to attach to.
175+
// This PowerShell instance is shared by the first PowerShellManager instance created in the pool,
176+
// and the dependency manager (used to download dependent modules if needed).
177+
var pwsh = Utils.NewPwshInstance();
178+
_powershellPool.Initialize(pwsh);
179+
180+
// Start the download asynchronously if needed.
181+
_dependencyManager.ProcessDependencyDownload(_msgStream, request, pwsh);
174182
}
175183
catch (Exception e)
176184
{

src/Utility/Utils.cs

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,43 +5,54 @@
55

66
using System;
77
using System.IO;
8-
using System.Management.Automation;
9-
using System.Management.Automation.Runspaces;
108
using System.Text;
119
using System.Threading;
1210
using Microsoft.PowerShell.Commands;
1311

1412
namespace Microsoft.Azure.Functions.PowerShellWorker.Utility
1513
{
14+
using System.Management.Automation;
15+
using System.Management.Automation.Runspaces;
16+
1617
internal class Utils
1718
{
1819
internal readonly static CmdletInfo ImportModuleCmdletInfo = new CmdletInfo("Import-Module", typeof(ImportModuleCommand));
1920
internal readonly static CmdletInfo RemoveModuleCmdletInfo = new CmdletInfo("Remove-Module", typeof(RemoveModuleCommand));
2021
internal readonly static CmdletInfo GetJobCmdletInfo = new CmdletInfo("Get-Job", typeof(GetJobCommand));
2122
internal readonly static CmdletInfo RemoveJobCmdletInfo = new CmdletInfo("Remove-Job", typeof(RemoveJobCommand));
2223

23-
internal readonly static Lazy<InitialSessionState> SingletonISS
24-
= new Lazy<InitialSessionState>(NewInitialSessionState, LazyThreadSafetyMode.PublicationOnly);
24+
private static InitialSessionState s_iss;
2525

26-
private static InitialSessionState NewInitialSessionState()
26+
/// <summary>
27+
/// Create a new PowerShell instance using our singleton InitialSessionState instance.
28+
/// </summary>
29+
internal static PowerShell NewPwshInstance()
2730
{
28-
var iss = InitialSessionState.CreateDefault();
29-
iss.ThreadOptions = PSThreadOptions.UseCurrentThread;
30-
iss.EnvironmentVariables.Add(
31-
new SessionStateVariableEntry(
32-
"PSModulePath",
33-
FunctionLoader.FunctionModulePath,
34-
description: null));
35-
36-
// Setting the execution policy on macOS and Linux throws an exception so only update it on Windows
37-
if(Platform.IsWindows)
31+
if (s_iss == null)
3832
{
39-
// This sets the execution policy on Windows to Unrestricted which is required to run the user's function scripts on
40-
// Windows client versions. This is needed if a user is testing their function locally with the func CLI.
41-
iss.ExecutionPolicy = Microsoft.PowerShell.ExecutionPolicy.Unrestricted;
33+
if (FunctionLoader.FunctionAppRootPath == null)
34+
{
35+
throw new InvalidOperationException(PowerShellWorkerStrings.FunctionAppRootNotResolved);
36+
}
37+
38+
s_iss = InitialSessionState.CreateDefault();
39+
s_iss.ThreadOptions = PSThreadOptions.UseCurrentThread;
40+
s_iss.EnvironmentVariables.Add(
41+
new SessionStateVariableEntry(
42+
"PSModulePath",
43+
FunctionLoader.FunctionModulePath,
44+
description: null));
45+
46+
// Setting the execution policy on macOS and Linux throws an exception so only update it on Windows
47+
if(Platform.IsWindows)
48+
{
49+
// This sets the execution policy on Windows to Unrestricted which is required to run the user's function scripts on
50+
// Windows client versions. This is needed if a user is testing their function locally with the func CLI.
51+
s_iss.ExecutionPolicy = Microsoft.PowerShell.ExecutionPolicy.Unrestricted;
52+
}
4253
}
4354

44-
return iss;
55+
return PowerShell.Create(s_iss);
4556
}
4657

4758
/// <summary>

test/Unit/PowerShell/PowerShellManagerTests.cs

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@
77
using System.Collections;
88
using System.Collections.Generic;
99
using System.IO;
10-
using System.Management.Automation;
1110
using Microsoft.Azure.Functions.PowerShellWorker.PowerShell;
11+
using Microsoft.Azure.Functions.PowerShellWorker.Utility;
1212
using Microsoft.Azure.WebJobs.Script.Grpc.Messages;
1313
using Xunit;
1414

1515
namespace Microsoft.Azure.Functions.PowerShellWorker.Test
1616
{
17+
using System.Management.Automation;
18+
1719
internal class TestUtils
1820
{
1921
internal const string TestInputBindingName = "req";
@@ -43,9 +45,9 @@ static TestUtils()
4345

4446
// Have a single place to get a PowerShellManager for testing.
4547
// This is to guarantee that the well known paths are setup before calling the constructor of PowerShellManager.
46-
internal static PowerShellManager NewTestPowerShellManager(ConsoleLogger logger)
48+
internal static PowerShellManager NewTestPowerShellManager(ConsoleLogger logger, PowerShell pwsh = null)
4749
{
48-
return new PowerShellManager(logger);
50+
return pwsh != null ? new PowerShellManager(logger, pwsh) : new PowerShellManager(logger, id: 2);
4951
}
5052

5153
internal static AzFunctionInfo NewAzFunctionInfo(string scriptFile, string entryPoint)
@@ -235,5 +237,26 @@ public void ProfileWithNonTerminatingError()
235237
Assert.Equal("Error: ERROR: help me!", _testLogger.FullLog[0]);
236238
Assert.Matches("Error: Fail to run profile.ps1. See logs for detailed errors. Profile location: ", _testLogger.FullLog[1]);
237239
}
240+
241+
[Fact]
242+
public void PSManagerCtorRunsProfileByDefault()
243+
{
244+
//initialize fresh log
245+
_testLogger.FullLog.Clear();
246+
TestUtils.NewTestPowerShellManager(_testLogger);
247+
248+
Assert.Single(_testLogger.FullLog);
249+
Assert.Equal($"Trace: No 'profile.ps1' is found at the FunctionApp root folder: {FunctionLoader.FunctionAppRootPath}.", _testLogger.FullLog[0]);
250+
}
251+
252+
[Fact]
253+
public void PSManagerCtorDoesNotRunProfileIfDelayInit()
254+
{
255+
//initialize fresh log
256+
_testLogger.FullLog.Clear();
257+
TestUtils.NewTestPowerShellManager(_testLogger, Utils.NewPwshInstance());
258+
259+
Assert.Empty(_testLogger.FullLog);
260+
}
238261
}
239262
}

0 commit comments

Comments
 (0)