PowerShellCmdlet.cs (23583B)
1 // ----------------------------------------------------------------------------- 2 // <copyright file="PowerShellCmdlet.cs" company="Microsoft Corporation"> 3 // Copyright (c) Microsoft Corporation. Licensed under the MIT License. 4 // </copyright> 5 // ----------------------------------------------------------------------------- 6 7 namespace Microsoft.WinGet.Common.Command 8 { 9 using System; 10 using System.Collections.Concurrent; 11 using System.Collections.Generic; 12 using System.Management.Automation; 13 using System.Runtime.ExceptionServices; 14 using System.Threading; 15 using System.Threading.Tasks; 16 using Microsoft.WinGet.Resources; 17 using Microsoft.WinGet.SharedLib.Exceptions; 18 using Microsoft.WinGet.SharedLib.PolicySettings; 19 20 /// <summary> 21 /// This must be the base class for every cmdlet for winget PowerShell modules. 22 /// It supports: 23 /// - Async operations. 24 /// - Execute on an MTA. If the thread is already running on an MTA it will executed it, otherwise 25 /// it will create a new MTA thread. 26 /// Wait must be used to synchronously wait con the task. 27 /// </summary> 28 public abstract class PowerShellCmdlet 29 { 30 private const string Debug = "Debug"; 31 private static readonly string[] WriteInformationTags = new string[] { "PSHOST" }; 32 33 private readonly PSCmdlet psCmdlet; 34 private readonly Thread pwshThread; 35 36 private readonly CancellationTokenSource source = new (); 37 private readonly SemaphoreSlim semaphore = new (1, 1); 38 private readonly ManualResetEventSlim pwshThreadActionReady = new (false); 39 private readonly ManualResetEventSlim pwshThreadActionCompleted = new (false); 40 41 private BlockingCollection<QueuedStream> queuedStreams = new (); 42 private int progressActivityId = 0; 43 private ConcurrentDictionary<int, ProgressRecordType> progressRecords = new (); 44 private Action? pwshThreadAction = null; 45 private ExceptionDispatchInfo? pwshThreadEdi = null; 46 47 /// <summary> 48 /// Initializes a new instance of the <see cref="PowerShellCmdlet"/> class. 49 /// </summary> 50 /// <param name="psCmdlet">PSCmdlet.</param> 51 /// <param name="policies">Policies.</param> 52 public PowerShellCmdlet(PSCmdlet psCmdlet, HashSet<Policy> policies) 53 { 54 // Passing Debug will make all the message actions to be Inquire. For async operations 55 // and the current queue message implementation this doesn't make sense. 56 // PowerShell will inquire for any message giving the impression that the task is 57 // paused, but the async operation is still running. 58 if (psCmdlet.MyInvocation.BoundParameters.ContainsKey(Debug)) 59 { 60 throw new NotSupportedException(Resources.DebugNotSupported); 61 } 62 63 this.ValidatePolicies(policies); 64 65 this.psCmdlet = psCmdlet; 66 this.pwshThread = Thread.CurrentThread; 67 } 68 69 /// <summary> 70 /// Request cancellation for this command. 71 /// </summary> 72 public void Cancel() 73 { 74 this.source.Cancel(); 75 } 76 77 /// <summary> 78 /// Execute the delegate in a MTA thread. 79 /// Caller must wait on task. 80 /// </summary> 81 /// <param name="func">Function to execute.</param> 82 /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> 83 internal Task RunOnMTA(Func<Task> func) 84 { 85 // .NET 4.8 doesn't support TaskCompletionSource. 86 #if POWERSHELL_WINDOWS 87 throw new NotImplementedException(); 88 #else 89 // This must be called in the main thread. 90 if (this.pwshThread != Thread.CurrentThread) 91 { 92 throw new InvalidOperationException(); 93 } 94 95 if (Thread.CurrentThread.GetApartmentState() == ApartmentState.MTA) 96 { 97 this.Write(StreamType.Verbose, "Already running on MTA"); 98 try 99 { 100 Task result = func(); 101 result.ContinueWith((task) => this.Complete(), TaskContinuationOptions.ExecuteSynchronously); 102 return result; 103 } 104 catch 105 { 106 this.Complete(); 107 throw; 108 } 109 } 110 111 this.Write(StreamType.Verbose, "Creating MTA thread"); 112 var tcs = new TaskCompletionSource(); 113 var thread = new Thread(() => 114 { 115 try 116 { 117 func().GetAwaiter().GetResult(); 118 tcs.SetResult(); 119 } 120 catch (Exception e) 121 { 122 tcs.SetException(e); 123 } 124 finally 125 { 126 this.Complete(); 127 } 128 }); 129 130 thread.SetApartmentState(ApartmentState.MTA); 131 thread.Start(); 132 return tcs.Task; 133 #endif 134 } 135 136 /// <summary> 137 /// Execute the delegate in a MTA thread. 138 /// Caller must wait on task. 139 /// </summary> 140 /// <param name="func">Function to execute.</param> 141 /// <typeparam name="TResult">Return type of function.</typeparam> 142 /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> 143 internal Task<TResult> RunOnMTA<TResult>(Func<Task<TResult>> func) 144 { 145 // This must be called in the main thread. 146 if (this.pwshThread != Thread.CurrentThread) 147 { 148 throw new InvalidOperationException(); 149 } 150 151 if (Thread.CurrentThread.GetApartmentState() == ApartmentState.MTA) 152 { 153 this.Write(StreamType.Verbose, "Already running on MTA"); 154 try 155 { 156 Task<TResult> result = func(); 157 result.ContinueWith((task) => this.Complete(), TaskContinuationOptions.ExecuteSynchronously); 158 return result; 159 } 160 catch 161 { 162 this.Complete(); 163 throw; 164 } 165 } 166 167 this.Write(StreamType.Verbose, "Creating MTA thread"); 168 var tcs = new TaskCompletionSource<TResult>(); 169 var thread = new Thread(() => 170 { 171 try 172 { 173 var result = func().GetAwaiter().GetResult(); 174 tcs.SetResult(result); 175 } 176 catch (Exception e) 177 { 178 tcs.SetException(e); 179 } 180 finally 181 { 182 this.Complete(); 183 } 184 }); 185 186 thread.SetApartmentState(ApartmentState.MTA); 187 thread.Start(); 188 return tcs.Task; 189 } 190 191 /// <summary> 192 /// Execute the delegate in a MTA thread. 193 /// Synchronous call. 194 /// </summary> 195 /// <param name="func">Function to execute.</param> 196 /// <typeparam name="TResult">Return type of function.</typeparam> 197 /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> 198 internal TResult RunOnMTA<TResult>(Func<TResult> func) 199 { 200 // This must be called in the main thread. 201 if (this.pwshThread != Thread.CurrentThread) 202 { 203 throw new InvalidOperationException(); 204 } 205 206 if (Thread.CurrentThread.GetApartmentState() == ApartmentState.MTA) 207 { 208 this.Write(StreamType.Verbose, "Already running on MTA"); 209 try 210 { 211 return func(); 212 } 213 finally 214 { 215 this.Complete(); 216 } 217 } 218 219 this.Write(StreamType.Verbose, "Creating MTA thread"); 220 var tcs = new TaskCompletionSource<TResult>(); 221 var thread = new Thread(() => 222 { 223 try 224 { 225 var result = func(); 226 tcs.SetResult(result); 227 } 228 catch (Exception e) 229 { 230 tcs.SetException(e); 231 } 232 finally 233 { 234 this.Complete(); 235 } 236 }); 237 238 thread.SetApartmentState(ApartmentState.MTA); 239 thread.Start(); 240 this.Wait(tcs.Task); 241 return tcs.Task.Result; 242 } 243 244 /// <summary> 245 /// Executes an action in the main thread. 246 /// Blocks until call is executed. 247 /// </summary> 248 /// <param name="action">Action to perform.</param> 249 internal void ExecuteInPowerShellThread(Action action) 250 { 251 if (this.pwshThread == Thread.CurrentThread) 252 { 253 action(); 254 return; 255 } 256 257 this.WaitForOurTurn(); 258 259 this.pwshThreadAction = action; 260 this.pwshThreadActionReady.Set(); 261 this.WaitMainThreadActionCompletion(); 262 } 263 264 /// <summary> 265 /// Waits for the task to be completed. This MUST be called from the main thread. 266 /// </summary> 267 /// <param name="runningTask">Task to wait for.</param> 268 /// <param name="writeCmdlet">The cmdlet that can write to PowerShell.</param> 269 internal void Wait(Task runningTask, PowerShellCmdlet? writeCmdlet = null) 270 { 271 writeCmdlet ??= this; 272 273 // This must be called in the main thread. 274 if (this.pwshThread != Thread.CurrentThread) 275 { 276 throw new InvalidOperationException(); 277 } 278 279 do 280 { 281 if (this.pwshThreadActionReady.IsSet) 282 { 283 // Someone needs the main thread. 284 this.pwshThreadActionReady.Reset(); 285 286 if (this.pwshThreadAction != null) 287 { 288 try 289 { 290 this.pwshThreadEdi = null; 291 this.pwshThreadAction(); 292 } 293 catch (Exception e) 294 { 295 // Make sure we don't throw in the PowerShell thread, this way 296 // we'll get a more meaningful stack by Get-Error. 297 this.pwshThreadEdi = ExceptionDispatchInfo.Capture(e); 298 } 299 300 this.pwshThreadAction = null; 301 } 302 303 // Done. 304 this.pwshThreadActionCompleted.Set(); 305 } 306 307 // Take from the blocking collection. 308 if (!this.queuedStreams.IsCompleted && this.queuedStreams.Count > 0) 309 { 310 try 311 { 312 var queuedOutput = this.queuedStreams.Take(); 313 if (queuedOutput != null) 314 { 315 this.CmdletWrite(queuedOutput.Type, queuedOutput.Data, writeCmdlet); 316 } 317 } 318 catch (InvalidOperationException) 319 { 320 // An InvalidOperationException means that Take() was called on a completed collection. 321 } 322 } 323 } 324 while (!(runningTask.IsCompleted && this.queuedStreams.IsCompleted)); 325 326 if (runningTask.IsFaulted) 327 { 328 // If IsFaulted is true, the task's Status is equal to Faulted, 329 // and its Exception property will be non-null. 330 AggregateException? ae = runningTask.Exception! as AggregateException; 331 if (ae != null && ae.InnerExceptions.Count == 1) 332 { 333 ExceptionDispatchInfo.Capture(ae.InnerExceptions[0]).Throw(); 334 } 335 336 throw runningTask.Exception!; 337 } 338 } 339 340 /// <summary> 341 /// Writes into the corresponding stream if running on the main thread. 342 /// Otherwise queue the message. 343 /// Is the caller responsibility to use the correct types. 344 /// </summary> 345 /// <param name="type">Stream type.</param> 346 /// <param name="data">Data.</param> 347 internal void Write(StreamType type, object data) 348 { 349 if (type == StreamType.Progress) 350 { 351 ProgressRecord progressRecord = (ProgressRecord)data; 352 if (progressRecord.RecordType == ProgressRecordType.Completed) 353 { 354 throw new NotSupportedException("Use CompleteProgress"); 355 } 356 357 // Keep track of all progress activity. 358 _ = this.progressRecords.TryAdd(progressRecord.ActivityId, progressRecord.RecordType); 359 } 360 361 if (this.pwshThread == Thread.CurrentThread) 362 { 363 this.CmdletWrite(type, data, this); 364 return; 365 } 366 367 this.queuedStreams.Add(new QueuedStream(type, data)); 368 } 369 370 /// <summary> 371 /// Helper to compute percentage and write progress for processing activities. 372 /// </summary> 373 /// <param name="activityId">Activity id.</param> 374 /// <param name="activity">The activity in progress.</param> 375 /// <param name="status">The status of the activity.</param> 376 /// <param name="completed">Number of completed actions.</param> 377 /// <param name="total">The expected total.</param> 378 internal void WriteProgressWithPercentage(int activityId, string activity, string status, int completed, int total) 379 { 380 double percentComplete = (double)completed / total; 381 var record = new ProgressRecord(activityId, activity, status) 382 { 383 RecordType = ProgressRecordType.Processing, 384 PercentComplete = (int)(100.0 * percentComplete), 385 }; 386 this.Write(StreamType.Progress, record); 387 } 388 389 /// <summary> 390 /// Helper to complete progress records. 391 /// </summary> 392 /// <param name="activityId">Activity id.</param> 393 /// <param name="activity">The activity in progress.</param> 394 /// <param name="status">The status of the activity.</param> 395 /// <param name="force">Force write complete progress.</param> 396 internal void CompleteProgress(int activityId, string activity, string status, bool force = false) 397 { 398 var record = new ProgressRecord(activityId, activity, status) 399 { 400 RecordType = ProgressRecordType.Completed, 401 PercentComplete = 100, 402 }; 403 404 if (!this.progressRecords.TryAdd(activityId, record.RecordType)) 405 { 406 _ = this.progressRecords.TryUpdate(activityId, record.RecordType, ProgressRecordType.Processing); 407 } 408 409 if (this.pwshThread == Thread.CurrentThread) 410 { 411 this.CmdletWrite(StreamType.Progress, record, this); 412 } 413 else 414 { 415 // You should only use force if you know the cmdlet that is completing this progress is a sync cmdlet that 416 // is running in an async context. A sync cmdlet is anything that doesn't start with Start-* 417 if (force) 418 { 419 this.ExecuteInPowerShellThread(() => this.CmdletWrite(StreamType.Progress, record, this)); 420 } 421 else 422 { 423 this.queuedStreams.Add(new QueuedStream(StreamType.Progress, record)); 424 } 425 } 426 } 427 428 /// <summary> 429 /// Writes to PowerShell streams. 430 /// This method must be called in the original thread. 431 /// WARNING: You must only call this when the task is completed. 432 /// </summary> 433 /// <param name="writeCmdlet">The cmdlet that can write to PowerShell.</param> 434 internal void ConsumeAndWriteStreams(PowerShellCmdlet writeCmdlet) 435 { 436 // This must be called in the main thread. 437 if (this.pwshThread != Thread.CurrentThread) 438 { 439 throw new InvalidOperationException(); 440 } 441 442 // Take from the blocking collection until is completed. 443 try 444 { 445 while (true) 446 { 447 var queuedOutput = this.queuedStreams.Take(); 448 if (queuedOutput != null) 449 { 450 this.CmdletWrite(queuedOutput.Type, queuedOutput.Data, writeCmdlet); 451 } 452 } 453 } 454 catch (InvalidOperationException) 455 { 456 // We are done. 457 // An InvalidOperationException means that Take() was called on a completed collection. 458 } 459 } 460 461 /// <summary> 462 /// Gets a new progress activity id. 463 /// </summary> 464 /// <returns>The new progress record id.</returns> 465 internal int GetNewProgressActivityId() 466 { 467 return Interlocked.Increment(ref this.progressActivityId); 468 } 469 470 /// <summary> 471 /// Gets the cancellation token. 472 /// </summary> 473 /// <returns>CancellationToken.</returns> 474 internal CancellationToken GetCancellationToken() 475 { 476 return this.source.Token; 477 } 478 479 /// <summary> 480 /// Gets the current file system location from the cmdlet. 481 /// </summary> 482 /// <returns>Path.</returns> 483 internal string GetCurrentFileSystemLocation() 484 { 485 return this.psCmdlet.SessionState.Path.CurrentFileSystemLocation.Path; 486 } 487 488 /// <summary> 489 /// Sets a variable. 490 /// </summary> 491 /// <param name="variableName">Variable name.</param> 492 /// <param name="value">Value.</param> 493 internal void SetVariable(string variableName, object value) 494 { 495 this.psCmdlet.SessionState.PSVariable.Set(variableName, value); 496 } 497 498 /// <summary> 499 /// Prompts the user if it should continue processing if possible. 500 /// </summary> 501 /// <param name="target">Message.</param> 502 /// <returns>If the operation should continue.</returns> 503 internal bool ShouldProcess(string target) 504 { 505 // If not on the main thread just continue. 506 if (this.pwshThread != Thread.CurrentThread) 507 { 508 return true; 509 } 510 511 return this.psCmdlet.ShouldProcess(target); 512 } 513 514 private void Complete() 515 { 516 this.queuedStreams.CompleteAdding(); 517 } 518 519 private void CmdletWrite(StreamType streamType, object data, PowerShellCmdlet writeCmdlet) 520 { 521 switch (streamType) 522 { 523 case StreamType.Debug: 524 throw new NotSupportedException(); 525 case StreamType.Verbose: 526 writeCmdlet.psCmdlet.WriteVerbose((string)data); 527 break; 528 case StreamType.Warning: 529 writeCmdlet.psCmdlet.WriteWarning((string)data); 530 break; 531 case StreamType.Error: 532 writeCmdlet.psCmdlet.WriteError((ErrorRecord)data); 533 break; 534 case StreamType.Progress: 535 // If the activity is already completed don't write progress. 536 var progressRecord = (ProgressRecord)data; 537 if (this.progressRecords[progressRecord.ActivityId] == progressRecord.RecordType) 538 { 539 writeCmdlet.psCmdlet.WriteProgress(progressRecord); 540 } 541 542 break; 543 case StreamType.Object: 544 writeCmdlet.psCmdlet.WriteObject(data); 545 break; 546 case StreamType.Information: 547 writeCmdlet.psCmdlet.WriteInformation(data, WriteInformationTags); 548 break; 549 } 550 } 551 552 private void ValidatePolicies(HashSet<Policy> policies) 553 { 554 GroupPolicy groupPolicy = GroupPolicy.GetInstance(); 555 556 if (policies.Contains(Policy.WinGet)) 557 { 558 if (!groupPolicy.IsEnabled(Policy.WinGet)) 559 { 560 throw new GroupPolicyException(Policy.WinGet, GroupPolicyFailureType.BlockedByPolicy); 561 } 562 563 policies.Remove(Policy.WinGet); 564 } 565 566 if (policies.Contains(Policy.Configuration)) 567 { 568 if (!groupPolicy.IsEnabled(Policy.Configuration)) 569 { 570 throw new GroupPolicyException(Policy.Configuration, GroupPolicyFailureType.BlockedByPolicy); 571 } 572 573 policies.Remove(Policy.Configuration); 574 } 575 576 if (policies.Contains(Policy.WinGetCommandLineInterfaces)) 577 { 578 if (!groupPolicy.IsEnabled(Policy.WinGetCommandLineInterfaces)) 579 { 580 throw new GroupPolicyException(Policy.WinGetCommandLineInterfaces, GroupPolicyFailureType.BlockedByPolicy); 581 } 582 583 policies.Remove(Policy.WinGetCommandLineInterfaces); 584 } 585 586 if (policies.Count > 0) 587 { 588 throw new NotSupportedException($"Invalid policies {string.Join(",", policies)}"); 589 } 590 } 591 592 private void WaitForOurTurn() 593 { 594 this.semaphore.Wait(this.GetCancellationToken()); 595 this.pwshThreadActionCompleted.Reset(); 596 } 597 598 private void WaitMainThreadActionCompletion() 599 { 600 WaitHandle.WaitAny(new[] 601 { 602 this.GetCancellationToken().WaitHandle, 603 this.pwshThreadActionCompleted.WaitHandle, 604 }); 605 606 try 607 { 608 if (this.pwshThreadEdi != null) 609 { 610 this.pwshThreadEdi.Throw(); 611 } 612 } 613 finally 614 { 615 this.semaphore.Release(); 616 } 617 } 618 619 private class QueuedStream 620 { 621 public QueuedStream(StreamType type, object data) 622 { 623 this.Type = type; 624 this.Data = data; 625 } 626 627 public StreamType Type { get; } 628 629 public object Data { get; } 630 } 631 } 632 }