From f71a02ec087550a27ecbee3aecd1a480b1ecea28 Mon Sep 17 00:00:00 2001 From: KirtiRamchandani Date: Sun, 24 May 2026 12:49:22 +0530 Subject: [PATCH 1/4] Avoid mutable ChildJobs enumeration in PSTaskJob --- .../engine/hostifaces/PSTask.cs | 11 ++++--- .../Foreach-Object-Parallel.Tests.ps1 | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/System.Management.Automation/engine/hostifaces/PSTask.cs b/src/System.Management.Automation/engine/hostifaces/PSTask.cs index 56bdabbff14..74a0a222976 100644 --- a/src/System.Management.Automation/engine/hostifaces/PSTask.cs +++ b/src/System.Management.Automation/engine/hostifaces/PSTask.cs @@ -998,6 +998,7 @@ public sealed class PSTaskJob : Job #region Members private readonly PSTaskPool _taskPool; + private readonly List _taskChildJobs; private bool _isOpen; private bool _stopSignaled; @@ -1031,6 +1032,7 @@ internal PSTaskJob( bool useNewRunspace) : base(command, string.Empty) { _taskPool = new PSTaskPool(throttleLimit, useNewRunspace); + _taskChildJobs = new List(); _isOpen = true; PSJobTypeName = nameof(PSTaskJob); @@ -1056,7 +1058,7 @@ public override bool HasMoreData { get { - foreach (var childJob in ChildJobs) + foreach (var childJob in _taskChildJobs) { if (childJob.HasMoreData) { @@ -1119,6 +1121,7 @@ internal bool AddJob(PSTaskChildJob childJob) } ChildJobs.Add(childJob); + _taskChildJobs.Add(childJob); return true; } @@ -1137,9 +1140,9 @@ internal void Start() System.Threading.ThreadPool.QueueUserWorkItem( (_) => { - foreach (var childJob in ChildJobs) + foreach (var childJob in _taskChildJobs) { - _taskPool.Add((PSTaskChildJob)childJob); + _taskPool.Add(childJob); } _taskPool.Close(); @@ -1162,7 +1165,7 @@ private void HandleTaskPoolComplete(object sender, EventArgs args) // Final state will be 'Complete', only if all child jobs completed successfully. JobState finalState = JobState.Completed; - foreach (var childJob in ChildJobs) + foreach (var childJob in _taskChildJobs) { if (childJob.JobStateInfo.State != JobState.Completed) { diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/Foreach-Object-Parallel.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/Foreach-Object-Parallel.Tests.ps1 index de384c6ad78..2cf25a2124c 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/Foreach-Object-Parallel.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/Foreach-Object-Parallel.Tests.ps1 @@ -486,6 +486,35 @@ Describe 'ForEach-Object -Parallel -AsJob Basic Tests' -Tags 'CI' { $job | Wait-Job | Remove-Job } + It 'Does not crash when the ChildJobs collection is mutated while a parallel job starts' { + $testScript = Join-Path $TestDrive 'Mutate-ChildJobs.ps1' + $stdoutPath = Join-Path $TestDrive 'stdout.txt' + $stderrPath = Join-Path $TestDrive 'stderr.txt' + Set-Content -LiteralPath $testScript -Value @' +$job = 0..20 | ForEach-Object -AsJob -Parallel { + Start-Sleep -Milliseconds 300 + $_ +} -ThrottleLimit 2 + +Start-Sleep -Milliseconds 100 +$job.ChildJobs.RemoveAt(15) + +$job | Wait-Job -Timeout 30 | Out-Null +$job | Remove-Job -Force +'@ + + $powershell = Join-Path -Path $PSHOME -ChildPath "pwsh" + $process = Start-Process -FilePath $powershell -ArgumentList @( + '-NoLogo' + '-NoProfile' + '-NonInteractive' + '-File' + $testScript + ) -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -Wait -PassThru + + $process.ExitCode | Should -Be 0 + } + It 'Verifies dollar underbar variable' { $expected = 1..10 From dad6d6066341571e0942f1f9cde00c1a579299cc Mon Sep 17 00:00:00 2001 From: KirtiRamchandani Date: Sun, 24 May 2026 18:44:06 +0530 Subject: [PATCH 2/4] Use ChildJobs snapshots in PSTaskJob --- .../engine/hostifaces/PSTask.cs | 10 ++++------ .../Foreach-Object-Parallel.Tests.ps1 | 9 +++++++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/System.Management.Automation/engine/hostifaces/PSTask.cs b/src/System.Management.Automation/engine/hostifaces/PSTask.cs index 74a0a222976..8b4c57777d4 100644 --- a/src/System.Management.Automation/engine/hostifaces/PSTask.cs +++ b/src/System.Management.Automation/engine/hostifaces/PSTask.cs @@ -4,6 +4,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; +using System.Linq; using System.Management.Automation.Host; using System.Management.Automation.Language; using System.Management.Automation.Remoting.Internal; @@ -998,7 +999,6 @@ public sealed class PSTaskJob : Job #region Members private readonly PSTaskPool _taskPool; - private readonly List _taskChildJobs; private bool _isOpen; private bool _stopSignaled; @@ -1032,7 +1032,6 @@ internal PSTaskJob( bool useNewRunspace) : base(command, string.Empty) { _taskPool = new PSTaskPool(throttleLimit, useNewRunspace); - _taskChildJobs = new List(); _isOpen = true; PSJobTypeName = nameof(PSTaskJob); @@ -1058,7 +1057,7 @@ public override bool HasMoreData { get { - foreach (var childJob in _taskChildJobs) + foreach (var childJob in ChildJobs.ToArray()) { if (childJob.HasMoreData) { @@ -1121,7 +1120,6 @@ internal bool AddJob(PSTaskChildJob childJob) } ChildJobs.Add(childJob); - _taskChildJobs.Add(childJob); return true; } @@ -1140,7 +1138,7 @@ internal void Start() System.Threading.ThreadPool.QueueUserWorkItem( (_) => { - foreach (var childJob in _taskChildJobs) + foreach (PSTaskChildJob childJob in ChildJobs.ToArray()) { _taskPool.Add(childJob); } @@ -1165,7 +1163,7 @@ private void HandleTaskPoolComplete(object sender, EventArgs args) // Final state will be 'Complete', only if all child jobs completed successfully. JobState finalState = JobState.Completed; - foreach (var childJob in _taskChildJobs) + foreach (var childJob in ChildJobs.ToArray()) { if (childJob.JobStateInfo.State != JobState.Completed) { diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/Foreach-Object-Parallel.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/Foreach-Object-Parallel.Tests.ps1 index 2cf25a2124c..174851cdc57 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/Foreach-Object-Parallel.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/Foreach-Object-Parallel.Tests.ps1 @@ -497,6 +497,15 @@ $job = 0..20 | ForEach-Object -AsJob -Parallel { } -ThrottleLimit 2 Start-Sleep -Milliseconds 100 +$deadline = [DateTime]::UtcNow.AddSeconds(30) +while ($job.ChildJobs.Count -le 15 -and [DateTime]::UtcNow -lt $deadline) { + Start-Sleep -Milliseconds 100 +} + +if ($job.ChildJobs.Count -le 15) { + throw "Timed out waiting for child jobs to be created." +} + $job.ChildJobs.RemoveAt(15) $job | Wait-Job -Timeout 30 | Out-Null From aca6a9064fa9a8c3ddcdf0024aaa72f69ccaa538 Mon Sep 17 00:00:00 2001 From: KirtiRamchandani Date: Sun, 24 May 2026 23:47:26 +0530 Subject: [PATCH 3/4] Address PSTaskJob review feedback --- .../engine/hostifaces/PSTask.cs | 47 +++++++++++++------ .../Foreach-Object-Parallel.Tests.ps1 | 19 +++++++- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/src/System.Management.Automation/engine/hostifaces/PSTask.cs b/src/System.Management.Automation/engine/hostifaces/PSTask.cs index 8b4c57777d4..698a831eaf3 100644 --- a/src/System.Management.Automation/engine/hostifaces/PSTask.cs +++ b/src/System.Management.Automation/engine/hostifaces/PSTask.cs @@ -4,7 +4,6 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; -using System.Linq; using System.Management.Automation.Host; using System.Management.Automation.Language; using System.Management.Automation.Remoting.Internal; @@ -999,6 +998,8 @@ public sealed class PSTaskJob : Job #region Members private readonly PSTaskPool _taskPool; + private readonly object _syncObject = new(); + private readonly List _taskChildJobs = new(); private bool _isOpen; private bool _stopSignaled; @@ -1057,11 +1058,14 @@ public override bool HasMoreData { get { - foreach (var childJob in ChildJobs.ToArray()) + lock (_syncObject) { - if (childJob.HasMoreData) + foreach (PSTaskChildJob childJob in _taskChildJobs) { - return true; + if (childJob.HasMoreData) + { + return true; + } } } @@ -1114,13 +1118,17 @@ protected override void Dispose(bool disposing) /// True when child job is successfully added. internal bool AddJob(PSTaskChildJob childJob) { - if (!_isOpen) + lock (_syncObject) { - return false; - } + if (!_isOpen) + { + return false; + } - ChildJobs.Add(childJob); - return true; + ChildJobs.Add(childJob); + _taskChildJobs.Add(childJob); + return true; + } } /// @@ -1129,7 +1137,13 @@ internal bool AddJob(PSTaskChildJob childJob) /// internal void Start() { - _isOpen = false; + PSTaskChildJob[] childJobs; + lock (_syncObject) + { + _isOpen = false; + childJobs = _taskChildJobs.ToArray(); + } + SetJobState(JobState.Running); // Submit jobs to the task pool, blocking when throttle limit is reached. @@ -1138,7 +1152,7 @@ internal void Start() System.Threading.ThreadPool.QueueUserWorkItem( (_) => { - foreach (PSTaskChildJob childJob in ChildJobs.ToArray()) + foreach (PSTaskChildJob childJob in childJobs) { _taskPool.Add(childJob); } @@ -1163,12 +1177,15 @@ private void HandleTaskPoolComplete(object sender, EventArgs args) // Final state will be 'Complete', only if all child jobs completed successfully. JobState finalState = JobState.Completed; - foreach (var childJob in ChildJobs.ToArray()) + lock (_syncObject) { - if (childJob.JobStateInfo.State != JobState.Completed) + foreach (PSTaskChildJob childJob in _taskChildJobs) { - finalState = JobState.Failed; - break; + if (childJob.JobStateInfo.State != JobState.Completed) + { + finalState = JobState.Failed; + break; + } } } diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/Foreach-Object-Parallel.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/Foreach-Object-Parallel.Tests.ps1 index 174851cdc57..d777f0a6154 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/Foreach-Object-Parallel.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/Foreach-Object-Parallel.Tests.ps1 @@ -496,7 +496,6 @@ $job = 0..20 | ForEach-Object -AsJob -Parallel { $_ } -ThrottleLimit 2 -Start-Sleep -Milliseconds 100 $deadline = [DateTime]::UtcNow.AddSeconds(30) while ($job.ChildJobs.Count -le 15 -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 100 @@ -513,14 +512,30 @@ $job | Remove-Job -Force '@ $powershell = Join-Path -Path $PSHOME -ChildPath "pwsh" + if ($IsWindows) { + $powershell += ".exe" + } + $process = Start-Process -FilePath $powershell -ArgumentList @( '-NoLogo' '-NoProfile' '-NonInteractive' '-File' $testScript - ) -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -Wait -PassThru + ) -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -PassThru + + try { + $process | Wait-Process -Timeout 60 -ErrorAction Stop + } + catch { + if (-not $process.HasExited) { + $process | Stop-Process -Force + } + + throw + } + $process.Refresh() $process.ExitCode | Should -Be 0 } From 87406ce142b4b8c260cf579d077e89fed00ce9cc Mon Sep 17 00:00:00 2001 From: KirtiRamchandani Date: Sun, 24 May 2026 23:49:51 +0530 Subject: [PATCH 4/4] Use platform-specific pwsh path in PSTaskJob test --- .../Foreach-Object-Parallel.Tests.ps1 | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/Foreach-Object-Parallel.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/Foreach-Object-Parallel.Tests.ps1 index d777f0a6154..41a33f3d783 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/Foreach-Object-Parallel.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/Foreach-Object-Parallel.Tests.ps1 @@ -511,11 +511,8 @@ $job | Wait-Job -Timeout 30 | Out-Null $job | Remove-Job -Force '@ - $powershell = Join-Path -Path $PSHOME -ChildPath "pwsh" - if ($IsWindows) { - $powershell += ".exe" - } - + $powershellName = if ($IsWindows) { "pwsh.exe" } else { "pwsh" } + $powershell = Join-Path -Path $PSHOME -ChildPath $powershellName $process = Start-Process -FilePath $powershell -ArgumentList @( '-NoLogo' '-NoProfile'