Skip to content

Commit b0bf31b

Browse files
Merge pull request #1936 from PowerShell/andschwa/onidle
Fix regression around `OnIdle` handler
2 parents adac9e5 + bef1b2e commit b0bf31b

File tree

4 files changed

+107
-49
lines changed

4 files changed

+107
-49
lines changed

Diff for: src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs

+6-14
Original file line numberDiff line numberDiff line change
@@ -503,18 +503,8 @@ private void Run()
503503
(PowerShell pwsh, RunspaceInfo localRunspaceInfo, EngineIntrinsics engineIntrinsics) = CreateInitialPowerShellSession();
504504
_mainRunspaceEngineIntrinsics = engineIntrinsics;
505505
_localComputerName = localRunspaceInfo.SessionDetails.ComputerName;
506-
507-
// NOTE: In order to support running events registered to PowerShell's OnIdle
508-
// handler, we have to have our top-level PowerShell instance be nested (otherwise
509-
// we get a PSInvalidOperationException because pipelines cannot be run
510-
// concurrently). Specifically this bug cropped up when a profile loaded code which
511-
// registered (and subsequently ran) on the OnIdle handler since it was hitting the
512-
// non-nested PowerShell instance. So now we just start with a nested instance.
513-
// While the PowerShell object is nested, as a frame type, this is our top-level
514-
// frame and therefore NOT nested in that sense.
515-
PowerShell nestedPwsh = CreateNestedPowerShell(localRunspaceInfo);
516-
_runspaceStack.Push(new RunspaceFrame(nestedPwsh.Runspace, localRunspaceInfo));
517-
PushPowerShellAndRunLoop(nestedPwsh, PowerShellFrameType.Normal | PowerShellFrameType.Repl, localRunspaceInfo);
506+
_runspaceStack.Push(new RunspaceFrame(pwsh.Runspace, localRunspaceInfo));
507+
PushPowerShellAndRunLoop(pwsh, PowerShellFrameType.Normal | PowerShellFrameType.Repl, localRunspaceInfo);
518508
}
519509
catch (Exception e)
520510
{
@@ -1015,7 +1005,9 @@ private static PowerShell CreateNestedPowerShell(RunspaceInfo currentRunspace)
10151005
// PowerShell.CreateNestedPowerShell() sets IsNested but not IsChild
10161006
// This means it throws due to the parent pipeline not running...
10171007
// So we must use the RunspaceMode.CurrentRunspace option on PowerShell.Create() instead
1018-
return PowerShell.Create(RunspaceMode.CurrentRunspace);
1008+
PowerShell pwsh = PowerShell.Create(RunspaceMode.CurrentRunspace);
1009+
pwsh.Runspace.ThreadOptions = PSThreadOptions.UseCurrentThread;
1010+
return pwsh;
10191011
}
10201012

10211013
private static PowerShell CreatePowerShellForRunspace(Runspace runspace)
@@ -1085,7 +1077,7 @@ private Runspace CreateInitialRunspace(InitialSessionState initialSessionState)
10851077
}
10861078

10871079
// NOTE: This token is received from PSReadLine, and it _is_ the ReadKey cancellation token!
1088-
private void OnPowerShellIdle(CancellationToken idleCancellationToken)
1080+
internal void OnPowerShellIdle(CancellationToken idleCancellationToken)
10891081
{
10901082
IReadOnlyList<PSEventSubscriber> eventSubscribers = _mainRunspaceEngineIntrinsics.Events.Subscribers;
10911083

Diff for: test/PowerShellEditorServices.Test.Shared/Profile/Test.PowerShellEditorServices_profile.ps1

+2
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@
55
function Assert-ProfileLoaded {
66
return $true
77
}
8+
9+
Register-EngineEvent -SourceIdentifier PowerShell.OnIdle -MaxTriggerCount 1 -Action { $global:handledInProfile = $true }

Diff for: test/PowerShellEditorServices.Test/PsesHostFactory.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ internal static class PsesHostFactory
2828

2929
public static readonly string BundledModulePath = Path.GetFullPath(TestUtilities.NormalizePath("../../../../../module"));
3030

31-
public static PsesInternalHost Create(ILoggerFactory loggerFactory)
31+
public static PsesInternalHost Create(ILoggerFactory loggerFactory, bool loadProfiles = false)
3232
{
3333
// We intentionally use `CreateDefault2()` as it loads `Microsoft.PowerShell.Core` only,
3434
// which is a more minimal and therefore safer state.
@@ -62,7 +62,7 @@ public static PsesInternalHost Create(ILoggerFactory loggerFactory)
6262
PsesInternalHost psesHost = new(loggerFactory, null, testHostDetails);
6363

6464
// NOTE: Because this is used by constructors it can't use await.
65-
if (psesHost.TryStartAsync(new HostStartOptions { LoadProfiles = false }, CancellationToken.None).GetAwaiter().GetResult())
65+
if (psesHost.TryStartAsync(new HostStartOptions { LoadProfiles = loadProfiles }, CancellationToken.None).GetAwaiter().GetResult())
6666
{
6767
return psesHost;
6868
}

Diff for: test/PowerShellEditorServices.Test/Session/PsesInternalHostTests.cs

+97-33
Original file line numberDiff line numberDiff line change
@@ -111,39 +111,6 @@ public async Task CanCancelExecutionWithMethod()
111111
Assert.True(executeTask.IsCanceled);
112112
}
113113

114-
[Fact]
115-
public async Task CanResolveAndLoadProfilesForHostId()
116-
{
117-
// Load the profiles for the test host name
118-
await psesHost.LoadHostProfilesAsync(CancellationToken.None).ConfigureAwait(true);
119-
120-
// Ensure that the $PROFILE variable is a string with the value of CurrentUserCurrentHost.
121-
IReadOnlyList<string> profileVariable = await psesHost.ExecutePSCommandAsync<string>(
122-
new PSCommand().AddScript("$PROFILE"),
123-
CancellationToken.None).ConfigureAwait(true);
124-
125-
Assert.Collection(profileVariable,
126-
(p) => Assert.Equal(PsesHostFactory.TestProfilePaths.CurrentUserCurrentHost, p));
127-
128-
// Ensure that all the profile paths are set in the correct note properties.
129-
IReadOnlyList<string> profileProperties = await psesHost.ExecutePSCommandAsync<string>(
130-
new PSCommand().AddScript("$PROFILE | Get-Member -Type NoteProperty"),
131-
CancellationToken.None).ConfigureAwait(true);
132-
133-
Assert.Collection(profileProperties,
134-
(p) => Assert.Equal($"string AllUsersAllHosts={PsesHostFactory.TestProfilePaths.AllUsersAllHosts}", p, ignoreCase: true),
135-
(p) => Assert.Equal($"string AllUsersCurrentHost={PsesHostFactory.TestProfilePaths.AllUsersCurrentHost}", p, ignoreCase: true),
136-
(p) => Assert.Equal($"string CurrentUserAllHosts={PsesHostFactory.TestProfilePaths.CurrentUserAllHosts}", p, ignoreCase: true),
137-
(p) => Assert.Equal($"string CurrentUserCurrentHost={PsesHostFactory.TestProfilePaths.CurrentUserCurrentHost}", p, ignoreCase: true));
138-
139-
// Ensure that the profile was loaded. The profile also checks that $PROFILE was defined.
140-
IReadOnlyList<bool> profileLoaded = await psesHost.ExecutePSCommandAsync<bool>(
141-
new PSCommand().AddScript("Assert-ProfileLoaded"),
142-
CancellationToken.None).ConfigureAwait(true);
143-
144-
Assert.Collection(profileLoaded, Assert.True);
145-
}
146-
147114
[Fact]
148115
public async Task CanHandleNoProfiles()
149116
{
@@ -202,6 +169,35 @@ public async Task CanHandleUndefinedPrompt()
202169
Assert.Equal(PsesInternalHost.DefaultPrompt, prompt);
203170
}
204171

172+
[Fact]
173+
public async Task CanRunOnIdleTask()
174+
{
175+
IReadOnlyList<PSObject> task = await psesHost.ExecutePSCommandAsync<PSObject>(
176+
new PSCommand().AddScript("$handled = $false; Register-EngineEvent -SourceIdentifier PowerShell.OnIdle -MaxTriggerCount 1 -Action { $global:handled = $true }"),
177+
CancellationToken.None).ConfigureAwait(true);
178+
179+
IReadOnlyList<bool> handled = await psesHost.ExecutePSCommandAsync<bool>(
180+
new PSCommand().AddScript("$handled"),
181+
CancellationToken.None).ConfigureAwait(true);
182+
183+
Assert.Collection(handled, (p) => Assert.False(p));
184+
185+
await psesHost.ExecuteDelegateAsync(
186+
nameof(psesHost.OnPowerShellIdle),
187+
executionOptions: null,
188+
(_, _) => psesHost.OnPowerShellIdle(CancellationToken.None),
189+
CancellationToken.None).ConfigureAwait(true);
190+
191+
// TODO: Why is this racy?
192+
Thread.Sleep(2000);
193+
194+
handled = await psesHost.ExecutePSCommandAsync<bool>(
195+
new PSCommand().AddScript("$handled"),
196+
CancellationToken.None).ConfigureAwait(true);
197+
198+
Assert.Collection(handled, (p) => Assert.True(p));
199+
}
200+
205201
[Fact]
206202
public async Task CanLoadPSReadLine()
207203
{
@@ -240,4 +236,72 @@ public async Task CanHandleBadInitialWorkingDirectory(string path)
240236
Assert.Collection(getLocation, (d) => Assert.Equal(cwd, d, ignoreCase: true));
241237
}
242238
}
239+
240+
[Trait("Category", "PsesInternalHost")]
241+
public class PsesInternalHostWithProfileTests : IDisposable
242+
{
243+
private readonly PsesInternalHost psesHost;
244+
245+
public PsesInternalHostWithProfileTests() => psesHost = PsesHostFactory.Create(NullLoggerFactory.Instance, loadProfiles: true);
246+
247+
public void Dispose()
248+
{
249+
#pragma warning disable VSTHRD002
250+
psesHost.StopAsync().Wait();
251+
#pragma warning restore VSTHRD002
252+
GC.SuppressFinalize(this);
253+
}
254+
255+
[Fact]
256+
public async Task CanResolveAndLoadProfilesForHostId()
257+
{
258+
// Ensure that the $PROFILE variable is a string with the value of CurrentUserCurrentHost.
259+
IReadOnlyList<string> profileVariable = await psesHost.ExecutePSCommandAsync<string>(
260+
new PSCommand().AddScript("$PROFILE"),
261+
CancellationToken.None).ConfigureAwait(true);
262+
263+
Assert.Collection(profileVariable,
264+
(p) => Assert.Equal(PsesHostFactory.TestProfilePaths.CurrentUserCurrentHost, p));
265+
266+
// Ensure that all the profile paths are set in the correct note properties.
267+
IReadOnlyList<string> profileProperties = await psesHost.ExecutePSCommandAsync<string>(
268+
new PSCommand().AddScript("$PROFILE | Get-Member -Type NoteProperty"),
269+
CancellationToken.None).ConfigureAwait(true);
270+
271+
Assert.Collection(profileProperties,
272+
(p) => Assert.Equal($"string AllUsersAllHosts={PsesHostFactory.TestProfilePaths.AllUsersAllHosts}", p, ignoreCase: true),
273+
(p) => Assert.Equal($"string AllUsersCurrentHost={PsesHostFactory.TestProfilePaths.AllUsersCurrentHost}", p, ignoreCase: true),
274+
(p) => Assert.Equal($"string CurrentUserAllHosts={PsesHostFactory.TestProfilePaths.CurrentUserAllHosts}", p, ignoreCase: true),
275+
(p) => Assert.Equal($"string CurrentUserCurrentHost={PsesHostFactory.TestProfilePaths.CurrentUserCurrentHost}", p, ignoreCase: true));
276+
277+
// Ensure that the profile was loaded. The profile also checks that $PROFILE was defined.
278+
IReadOnlyList<bool> profileLoaded = await psesHost.ExecutePSCommandAsync<bool>(
279+
new PSCommand().AddScript("Assert-ProfileLoaded"),
280+
CancellationToken.None).ConfigureAwait(true);
281+
282+
Assert.Collection(profileLoaded, Assert.True);
283+
}
284+
285+
// This test specifically relies on a handler registered in the test profile, and on the
286+
// test host loading the profiles during startup, that way the pipeline timing is
287+
// consistent.
288+
[Fact]
289+
public async Task CanRunOnIdleInProfileTask()
290+
{
291+
await psesHost.ExecuteDelegateAsync(
292+
nameof(psesHost.OnPowerShellIdle),
293+
executionOptions: null,
294+
(_, _) => psesHost.OnPowerShellIdle(CancellationToken.None),
295+
CancellationToken.None).ConfigureAwait(true);
296+
297+
// TODO: Why is this racy?
298+
Thread.Sleep(2000);
299+
300+
IReadOnlyList<bool> handled = await psesHost.ExecutePSCommandAsync<bool>(
301+
new PSCommand().AddScript("$handledInProfile"),
302+
CancellationToken.None).ConfigureAwait(true);
303+
304+
Assert.Collection(handled, (p) => Assert.True(p));
305+
}
306+
}
243307
}

0 commit comments

Comments
 (0)