From ab70dc5b845e1b25f3aa486c2c83a1103deec70c Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 12 Aug 2026 15:00:59 +0200 Subject: [PATCH 1/4] Serialize first test dispatch to mitigate GDI+ first-touch race On a fresh container the first test run intermittently fails with a masked client 500 on page 130455. The real root cause is a platform GDI+/System.Drawing first-touch race: the image-encoder registry initializes lazily once per NST process and is not thread-safe, so concurrent per-tenant company-opens (media import during InitCompany) hit ArgumentNullException('encoder') and terminate the session. A fixed 5s inter-dispatch sleep already exists and is insufficient (it staggers dispatch start, not the async GDI+ moment). Instead, run the first app alone and await its completion so GDI+ warms single-threaded before the parallel fan-out. Complementary to the existing transient-race one-retry path, which is unchanged. No-op for single-app/single-tenant runs. Pipeline-level workaround; remove once the platform guards its first-touch GDI+ init. --- build/scripts/ParallelTestExecution.psm1 | 86 +++++++++++++++++- .../tests/ParallelTestExecution.Test.ps1 | 88 +++++++++++++++++++ 2 files changed, 173 insertions(+), 1 deletion(-) diff --git a/build/scripts/ParallelTestExecution.psm1 b/build/scripts/ParallelTestExecution.psm1 index 73efb2dd329..2b6ea5f0133 100644 --- a/build/scripts/ParallelTestExecution.psm1 +++ b/build/scripts/ParallelTestExecution.psm1 @@ -564,6 +564,84 @@ function Merge-TenantTestResults { } } +<# +.SYNOPSIS + Serializes the FIRST test dispatch to warm the container's process-wide GDI+ state before the + parallel fan-out, mitigating a platform race. Returns the remaining apps still to dispatch. +.DESCRIPTION + PLATFORM BUG MITIGATION (pipeline-level workaround; not an app/test bug). + + On a fresh container the first test run intermittently fails. The client-side symptom is a + masked "InvokeInteractions failed with status code 500" / "Cannot open page 130455", but the + real root cause (server event log) is a GDI+/System.Drawing first-touch race: the encoder + registry (ImageCodecInfo) initializes lazily, once per NST process, on first use of Image.Save, + and is not thread-safe. The first company-open imports a media object (Codeunit 151 InitCompany + / Codeunit 20419 checklist) which calls NavMediaImage.Bytes() -> Image.Save -> GDI+. When + several tenants' company-opens make that first call CONCURRENTLY, encoder resolution returns + null -> System.ArgumentNullException('encoder'), terminating the server session. Once GDI+ is + warm, every later/retried open succeeds (that is why the existing one-retry path recovers). + + A fixed delay is NOT sufficient and is already present: Start-TestAppDispatch sleeps 5s between + dispatches for exactly this reason, yet all tenants still raced at the same instant. The sleep + staggers dispatch START, not the asynchronous GDI+ moment inside the NST. The deterministic fix + is to let ONE company-open run alone and AWAIT it to completion, warming GDI+ single-threaded, + before any parallel open starts. This exercises the real 130455/company-open/media path by + running the first test app for real; its result is captured normally (a transient race on the + warmup app itself flows into $State.transient and the caller re-queues it). + + Warm ONCE per container/NST (the state is process-wide) - never per tenant. No-op when there is + a single app (no fan-out) or a single tenant (already serial, so no race). + + TODO: remove this serialization once the platform guards its first-touch GDI+ initialization. +.PARAMETER Pending + The ordered list of app names still to dispatch. The first app is consumed for the warmup. +.PARAMETER AppIdByName + Map of app name -> extensionId. If the first app's id cannot be resolved, warmup is skipped. +.PARAMETER Tenants + All available tenant ids. Warmup dispatches onto the first one. +.PARAMETER State + The parallel execution state object; mutated (jobs/hasFailures/transient) as the warmup runs. +.OUTPUTS + [string[]] The remaining app names to dispatch (first app removed if it was warmed up). +#> +function Invoke-GdiPlusWarmupDispatch { + param( + [Parameter(Mandatory=$true)][Hashtable]$Parameters, + [Parameter(Mandatory=$true)][AllowEmptyCollection()][string[]]$Pending, + [Parameter(Mandatory=$true)][Hashtable]$AppIdByName, + [Parameter(Mandatory=$true)][AllowEmptyCollection()][string[]]$Tenants, + [Parameter(Mandatory=$true)][string]$ScriptPath, + [string]$TestType, + [Parameter(Mandatory=$true)]$State + ) + + # Only serialize when there is a fan-out to protect: >1 app AND >1 tenant. A single tenant is + # already serial (no concurrency), and a single app never fans out. + if ($Pending.Count -le 1 -or $Tenants.Count -le 1) { + return @($Pending) + } + + $warmupApp = $Pending[0] + $warmupAppId = $AppIdByName[$warmupApp] + if (-not $warmupAppId) { + # Leave the app in the queue so the main loop emits its usual "could not resolve appId" + # warning; just skip the warmup rather than silently dropping it. + return @($Pending) + } + + Write-Host "GDI+ warmup: dispatching first app '$warmupApp' on '$($Tenants[0])' alone and awaiting completion before parallel fan-out (mitigates platform GDI+ first-touch race)." + Start-TestAppDispatch -Parameters $Parameters -AppName $warmupApp -AppId $warmupAppId -Tenant $Tenants[0] ` + -ScriptPath $ScriptPath -TestType $TestType -State $State -Verb 'Dispatching' + + # Await the single warmup job to completion so GDI+ is warm before anything runs in parallel. + # Wait-ForAllTestJobs receives the result (updating $State.hasFailures/transient) and clears + # $State.jobs. If the warmup app itself lost the race it now sits in $State.transient, and the + # caller's dispatch loop re-queues it via the normal one-retry path. + if (-not (Wait-ForAllTestJobs -state $State)) { $State.hasFailures = $true } + + return @($Pending | Select-Object -Skip 1) +} + <# .SYNOPSIS Dispatches test apps in parallel across all available tenants in a BC container. @@ -639,6 +717,12 @@ function Invoke-ParallelTestExecution { # $state.retried gets classified as Failed (not Transient) on a second failure. $pending = @($appNamesToTest) + # PLATFORM GDI+ first-touch race mitigation: run the first app alone and await it so the NST's + # process-wide GDI+ encoder registry initializes single-threaded before the parallel fan-out. + # See Invoke-GdiPlusWarmupDispatch for the full rationale. No-op for single-app/single-tenant. + $pending = @(Invoke-GdiPlusWarmupDispatch -Parameters $parameters -Pending $pending -AppIdByName $appIdByName ` + -Tenants $tenants -ScriptPath $scriptPath -TestType $testType -State $state) + while ($pending.Count -gt 0 -or $state.jobs.Count -gt 0 -or $state.transient.Count -gt 0) { # Promote any transient failures back into the dispatch queue. They go to the FRONT: # a platform race normally kills a job within a minute of dispatch, so the victim is @@ -732,4 +816,4 @@ function Invoke-PerProjectTestRun { return (. $script -parameters $parameters -TestType $testType -AppNamesToTest $appNamesToTest) } -Export-ModuleMember -Function Invoke-ParallelTestExecution, Get-AvailableBcTenants, Get-CachedTestRunResult, Get-InstalledTestAppNames, Get-AppNamesForBucket, Invoke-PerProjectTestRun, Get-AppNameFromMetadata +Export-ModuleMember -Function Invoke-ParallelTestExecution, Get-AvailableBcTenants, Get-CachedTestRunResult, Get-InstalledTestAppNames, Get-AppNamesForBucket, Invoke-PerProjectTestRun, Get-AppNameFromMetadata, Invoke-GdiPlusWarmupDispatch diff --git a/build/scripts/tests/ParallelTestExecution.Test.ps1 b/build/scripts/tests/ParallelTestExecution.Test.ps1 index cbd0805ec80..ddf1319d452 100644 --- a/build/scripts/tests/ParallelTestExecution.Test.ps1 +++ b/build/scripts/tests/ParallelTestExecution.Test.ps1 @@ -139,3 +139,91 @@ Describe "ParallelTestExecution transient retry scheduling" { } } } + +Describe "ParallelTestExecution GDI+ warmup (serialize-first)" { + BeforeAll { + Import-Module (Join-Path $PSScriptRoot '../ParallelTestExecution.psm1') -Force + } + + It "dispatches the first app alone and awaits it before fanning out the rest" { + # PLATFORM GDI+ first-touch race mitigation: the first company-open must run alone and be + # awaited to completion so the NST's process-wide GDI+ state warms single-threaded before + # any parallel open. This asserts exactly that ordering: dispatch(first) -> wait -> rest. + InModuleScope ParallelTestExecution { + $script:events = [System.Collections.Generic.List[string]]::new() + + Mock Get-AvailableBcTenants { @('default', 'tenant2') } + Mock Get-BcContainerAppInfo { + @('Big', 'Medium', 'Small') | ForEach-Object { + [PSCustomObject]@{ IsInstalled = $true; Name = $_; AppId = "id-$_" } + } + } + Mock Wait-ForFreeTenant { 'tenant2' } + Mock Merge-TenantTestResults { } + Mock Start-TestAppDispatch { $script:events.Add("dispatch:$AppName") } + Mock Wait-ForAllTestJobs { $script:events.Add('wait'); $true } + + $params = @{ containerName = "ut-$([guid]::NewGuid().ToString('N'))"; tenant = 'default' } + $null = Invoke-ParallelTestExecution -parameters $params -scriptPath 'unused.ps1' ` + -testType 'Legacy' -appNamesToTest @('Big', 'Medium', 'Small') + + # First app dispatched alone, then awaited, before any other app is dispatched. + $script:events[0] | Should -Be 'dispatch:Big' + $script:events[1] | Should -Be 'wait' + $waitIndex = $script:events.IndexOf('wait') + $script:events.IndexOf('dispatch:Medium') | Should -BeGreaterThan $waitIndex + $script:events.IndexOf('dispatch:Small') | Should -BeGreaterThan $waitIndex + } + } + + It "skips the serial warmup when only one tenant is available (already serial, no race)" { + InModuleScope ParallelTestExecution { + $script:events = [System.Collections.Generic.List[string]]::new() + + Mock Get-AvailableBcTenants { @('default') } + Mock Get-BcContainerAppInfo { + @('Big', 'Medium') | ForEach-Object { + [PSCustomObject]@{ IsInstalled = $true; Name = $_; AppId = "id-$_" } + } + } + Mock Wait-ForFreeTenant { 'default' } + Mock Merge-TenantTestResults { } + Mock Start-TestAppDispatch { $script:events.Add("dispatch:$AppName") } + Mock Wait-ForAllTestJobs { $script:events.Add('wait'); $true } + + $params = @{ containerName = "ut-$([guid]::NewGuid().ToString('N'))"; tenant = 'default' } + $null = Invoke-ParallelTestExecution -parameters $params -scriptPath 'unused.ps1' ` + -testType 'Legacy' -appNamesToTest @('Big', 'Medium') + + # No serial warmup: the two apps are dispatched by the normal loop with no leading + # solo dispatch+await. (Start-TestAppDispatch is mocked so no jobs accumulate, hence the + # loop's terminal Wait-ForAllTestJobs is not reached - the key point is no 'wait' was + # emitted by a warmup step.) + $script:events | Should -Be @('dispatch:Big', 'dispatch:Medium') + $script:events | Should -Not -Contain 'wait' + } + } + + It "returns the pending list unchanged when there is a single tenant" { + InModuleScope ParallelTestExecution { + $state = [PSCustomObject]@{ jobs = @(); hasFailures = $false; transient = @(); retried = @{} } + $result = Invoke-GdiPlusWarmupDispatch -Parameters @{ containerName = 'c' } ` + -Pending @('A', 'B', 'C') -AppIdByName @{ A = 'id-A'; B = 'id-B'; C = 'id-C' } ` + -Tenants @('default') -ScriptPath 'unused.ps1' -TestType 'Legacy' -State $state + $result | Should -Be @('A', 'B', 'C') + } + } + + It "returns the pending list unchanged when there is a single app" { + InModuleScope ParallelTestExecution { + Mock Start-TestAppDispatch { } + Mock Wait-ForAllTestJobs { $true } + $state = [PSCustomObject]@{ jobs = @(); hasFailures = $false; transient = @(); retried = @{} } + $result = Invoke-GdiPlusWarmupDispatch -Parameters @{ containerName = 'c' } ` + -Pending @('Only') -AppIdByName @{ Only = 'id-Only' } ` + -Tenants @('default', 'tenant2') -ScriptPath 'unused.ps1' -TestType 'Legacy' -State $state + $result | Should -Be @('Only') + Should -Invoke Start-TestAppDispatch -Times 0 + } + } +} From 0190b944c5c282d4b31e34c2535189a59fc5e985 Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Thu, 13 Aug 2026 12:06:28 +0200 Subject: [PATCH 2/4] Trim comments, rename to Invoke-WarmupDispatch, warm up with smallest app Shorten the warmup function/description comments, rename Invoke-GdiPlusWarmupDispatch to Invoke-WarmupDispatch, and reorder each legacy bucket so the smallest app runs first. Since the first app now runs serially before the parallel fan-out, leading with the smallest keeps that serial step cheap while the remaining apps stay longest-first for LPT scheduling. --- build/scripts/ParallelTestExecution.psm1 | 64 ++++++------------- build/scripts/TestConfiguration.json | 8 +-- .../tests/ParallelTestExecution.Test.ps1 | 20 +++--- 3 files changed, 32 insertions(+), 60 deletions(-) diff --git a/build/scripts/ParallelTestExecution.psm1 b/build/scripts/ParallelTestExecution.psm1 index 2b6ea5f0133..eb54af6df63 100644 --- a/build/scripts/ParallelTestExecution.psm1 +++ b/build/scripts/ParallelTestExecution.psm1 @@ -566,33 +566,13 @@ function Merge-TenantTestResults { <# .SYNOPSIS - Serializes the FIRST test dispatch to warm the container's process-wide GDI+ state before the - parallel fan-out, mitigating a platform race. Returns the remaining apps still to dispatch. + Runs the first test app alone and awaits it before the parallel fan-out. Returns the remaining + apps still to dispatch. .DESCRIPTION - PLATFORM BUG MITIGATION (pipeline-level workaround; not an app/test bug). - - On a fresh container the first test run intermittently fails. The client-side symptom is a - masked "InvokeInteractions failed with status code 500" / "Cannot open page 130455", but the - real root cause (server event log) is a GDI+/System.Drawing first-touch race: the encoder - registry (ImageCodecInfo) initializes lazily, once per NST process, on first use of Image.Save, - and is not thread-safe. The first company-open imports a media object (Codeunit 151 InitCompany - / Codeunit 20419 checklist) which calls NavMediaImage.Bytes() -> Image.Save -> GDI+. When - several tenants' company-opens make that first call CONCURRENTLY, encoder resolution returns - null -> System.ArgumentNullException('encoder'), terminating the server session. Once GDI+ is - warm, every later/retried open succeeds (that is why the existing one-retry path recovers). - - A fixed delay is NOT sufficient and is already present: Start-TestAppDispatch sleeps 5s between - dispatches for exactly this reason, yet all tenants still raced at the same instant. The sleep - staggers dispatch START, not the asynchronous GDI+ moment inside the NST. The deterministic fix - is to let ONE company-open run alone and AWAIT it to completion, warming GDI+ single-threaded, - before any parallel open starts. This exercises the real 130455/company-open/media path by - running the first test app for real; its result is captured normally (a transient race on the - warmup app itself flows into $State.transient and the caller re-queues it). - - Warm ONCE per container/NST (the state is process-wide) - never per tenant. No-op when there is - a single app (no fan-out) or a single tenant (already serial, so no race). - - TODO: remove this serialization once the platform guards its first-touch GDI+ initialization. + Concurrent per-tenant company-opens can race on the first use of the container's process-wide + GDI+ state, so the first open is serialized: one app runs alone and is awaited to completion, + then the caller fans out the rest. Warms once per container - no-op for a single app or tenant. + A transient failure on the warmed-up app flows into $State.transient and is re-queued normally. .PARAMETER Pending The ordered list of app names still to dispatch. The first app is consumed for the warmup. .PARAMETER AppIdByName @@ -604,7 +584,7 @@ function Merge-TenantTestResults { .OUTPUTS [string[]] The remaining app names to dispatch (first app removed if it was warmed up). #> -function Invoke-GdiPlusWarmupDispatch { +function Invoke-WarmupDispatch { param( [Parameter(Mandatory=$true)][Hashtable]$Parameters, [Parameter(Mandatory=$true)][AllowEmptyCollection()][string[]]$Pending, @@ -615,8 +595,7 @@ function Invoke-GdiPlusWarmupDispatch { [Parameter(Mandatory=$true)]$State ) - # Only serialize when there is a fan-out to protect: >1 app AND >1 tenant. A single tenant is - # already serial (no concurrency), and a single app never fans out. + # Only serialize when there is a fan-out to protect: >1 app AND >1 tenant. if ($Pending.Count -le 1 -or $Tenants.Count -le 1) { return @($Pending) } @@ -624,19 +603,16 @@ function Invoke-GdiPlusWarmupDispatch { $warmupApp = $Pending[0] $warmupAppId = $AppIdByName[$warmupApp] if (-not $warmupAppId) { - # Leave the app in the queue so the main loop emits its usual "could not resolve appId" - # warning; just skip the warmup rather than silently dropping it. + # Leave the app in the queue so the main loop emits its usual appId warning. return @($Pending) } - Write-Host "GDI+ warmup: dispatching first app '$warmupApp' on '$($Tenants[0])' alone and awaiting completion before parallel fan-out (mitigates platform GDI+ first-touch race)." + Write-Host "Warming up: dispatching first app '$warmupApp' on '$($Tenants[0])' alone and awaiting completion before parallel fan-out." Start-TestAppDispatch -Parameters $Parameters -AppName $warmupApp -AppId $warmupAppId -Tenant $Tenants[0] ` -ScriptPath $ScriptPath -TestType $TestType -State $State -Verb 'Dispatching' - # Await the single warmup job to completion so GDI+ is warm before anything runs in parallel. - # Wait-ForAllTestJobs receives the result (updating $State.hasFailures/transient) and clears - # $State.jobs. If the warmup app itself lost the race it now sits in $State.transient, and the - # caller's dispatch loop re-queues it via the normal one-retry path. + # Await the single job so the process is warm before anything runs in parallel. A transient + # failure here lands in $State.transient and the caller's loop re-queues it. if (-not (Wait-ForAllTestJobs -state $State)) { $State.hasFailures = $true } return @($Pending | Select-Object -Skip 1) @@ -711,16 +687,14 @@ function Invoke-ParallelTestExecution { $state = [PSCustomObject]@{ jobs = @(); dispatched = $true; completed = $false; finalResult = $false; hasFailures = $false; transient = @(); retried = @{} } $state | ConvertTo-Json -Depth 5 | Set-Content $stateFile -Force - # Single dispatch loop. $pending is processed FIFO and $appNamesToTest arrives ordered - # longest-first (see TestConfiguration.json), which is the LPT schedule that keeps the - # tail short. The retry cap lives in Receive-TestJobResult: an app already in - # $state.retried gets classified as Failed (not Transient) on a second failure. + # Single dispatch loop, FIFO. TestConfiguration.json lists the smallest app first (a cheap + # serial warmup) and the rest longest-first (LPT, keeps the tail short). The retry cap lives in + # Receive-TestJobResult: an app already in $state.retried is classified as Failed on a re-fail. $pending = @($appNamesToTest) - # PLATFORM GDI+ first-touch race mitigation: run the first app alone and await it so the NST's - # process-wide GDI+ encoder registry initializes single-threaded before the parallel fan-out. - # See Invoke-GdiPlusWarmupDispatch for the full rationale. No-op for single-app/single-tenant. - $pending = @(Invoke-GdiPlusWarmupDispatch -Parameters $parameters -Pending $pending -AppIdByName $appIdByName ` + # Run the first app alone and await it to warm the container before parallelizing the rest. + # No-op for single-app/single-tenant. + $pending = @(Invoke-WarmupDispatch -Parameters $parameters -Pending $pending -AppIdByName $appIdByName ` -Tenants $tenants -ScriptPath $scriptPath -TestType $testType -State $state) while ($pending.Count -gt 0 -or $state.jobs.Count -gt 0 -or $state.transient.Count -gt 0) { @@ -816,4 +790,4 @@ function Invoke-PerProjectTestRun { return (. $script -parameters $parameters -TestType $testType -AppNamesToTest $appNamesToTest) } -Export-ModuleMember -Function Invoke-ParallelTestExecution, Get-AvailableBcTenants, Get-CachedTestRunResult, Get-InstalledTestAppNames, Get-AppNamesForBucket, Invoke-PerProjectTestRun, Get-AppNameFromMetadata, Invoke-GdiPlusWarmupDispatch +Export-ModuleMember -Function Invoke-ParallelTestExecution, Get-AvailableBcTenants, Get-CachedTestRunResult, Get-InstalledTestAppNames, Get-AppNamesForBucket, Invoke-PerProjectTestRun, Get-AppNameFromMetadata, Invoke-WarmupDispatch diff --git a/build/scripts/TestConfiguration.json b/build/scripts/TestConfiguration.json index 9a98ddbb4e0..cfe627d2ebc 100644 --- a/build/scripts/TestConfiguration.json +++ b/build/scripts/TestConfiguration.json @@ -1,5 +1,6 @@ { "LegacyTests-Bucket1": [ + "Tests-Local", "Tests-Workflow", "AlCosting", "Tests-ERM-Application", @@ -23,10 +24,10 @@ "Tests-User", "Tests-Cash Flow", "Tests-Resource", - "Tests-Azure AI", - "Tests-Local" + "Tests-Azure AI" ], "LegacyTests-Bucket2": [ + "Tests-Upgrade", "Tests-SCM-Assembly", "Tests-SCM", "Tests-SCM-Workflow", @@ -49,7 +50,6 @@ "Tests-Monitor Sensitive Fields", "Performance Toolkit Samples", "Tests-Integration-Internal", - "Tests-DotNet-Internal", - "Tests-Upgrade" + "Tests-DotNet-Internal" ] } diff --git a/build/scripts/tests/ParallelTestExecution.Test.ps1 b/build/scripts/tests/ParallelTestExecution.Test.ps1 index ddf1319d452..bc84fd954c3 100644 --- a/build/scripts/tests/ParallelTestExecution.Test.ps1 +++ b/build/scripts/tests/ParallelTestExecution.Test.ps1 @@ -140,15 +140,14 @@ Describe "ParallelTestExecution transient retry scheduling" { } } -Describe "ParallelTestExecution GDI+ warmup (serialize-first)" { +Describe "ParallelTestExecution warmup dispatch" { BeforeAll { Import-Module (Join-Path $PSScriptRoot '../ParallelTestExecution.psm1') -Force } It "dispatches the first app alone and awaits it before fanning out the rest" { - # PLATFORM GDI+ first-touch race mitigation: the first company-open must run alone and be - # awaited to completion so the NST's process-wide GDI+ state warms single-threaded before - # any parallel open. This asserts exactly that ordering: dispatch(first) -> wait -> rest. + # The first app must run alone and be awaited before any parallel dispatch. This asserts + # exactly that ordering: dispatch(first) -> wait -> rest. InModuleScope ParallelTestExecution { $script:events = [System.Collections.Generic.List[string]]::new() @@ -176,7 +175,7 @@ Describe "ParallelTestExecution GDI+ warmup (serialize-first)" { } } - It "skips the serial warmup when only one tenant is available (already serial, no race)" { + It "skips the warmup dispatch when only one tenant is available" { InModuleScope ParallelTestExecution { $script:events = [System.Collections.Generic.List[string]]::new() @@ -195,10 +194,9 @@ Describe "ParallelTestExecution GDI+ warmup (serialize-first)" { $null = Invoke-ParallelTestExecution -parameters $params -scriptPath 'unused.ps1' ` -testType 'Legacy' -appNamesToTest @('Big', 'Medium') - # No serial warmup: the two apps are dispatched by the normal loop with no leading - # solo dispatch+await. (Start-TestAppDispatch is mocked so no jobs accumulate, hence the - # loop's terminal Wait-ForAllTestJobs is not reached - the key point is no 'wait' was - # emitted by a warmup step.) + # No warmup dispatch: the two apps are dispatched by the normal loop with no leading + # solo dispatch+await. (Start-TestAppDispatch is mocked so no jobs accumulate, hence + # the loop's terminal Wait-ForAllTestJobs is not reached.) $script:events | Should -Be @('dispatch:Big', 'dispatch:Medium') $script:events | Should -Not -Contain 'wait' } @@ -207,7 +205,7 @@ Describe "ParallelTestExecution GDI+ warmup (serialize-first)" { It "returns the pending list unchanged when there is a single tenant" { InModuleScope ParallelTestExecution { $state = [PSCustomObject]@{ jobs = @(); hasFailures = $false; transient = @(); retried = @{} } - $result = Invoke-GdiPlusWarmupDispatch -Parameters @{ containerName = 'c' } ` + $result = Invoke-WarmupDispatch -Parameters @{ containerName = 'c' } ` -Pending @('A', 'B', 'C') -AppIdByName @{ A = 'id-A'; B = 'id-B'; C = 'id-C' } ` -Tenants @('default') -ScriptPath 'unused.ps1' -TestType 'Legacy' -State $state $result | Should -Be @('A', 'B', 'C') @@ -219,7 +217,7 @@ Describe "ParallelTestExecution GDI+ warmup (serialize-first)" { Mock Start-TestAppDispatch { } Mock Wait-ForAllTestJobs { $true } $state = [PSCustomObject]@{ jobs = @(); hasFailures = $false; transient = @(); retried = @{} } - $result = Invoke-GdiPlusWarmupDispatch -Parameters @{ containerName = 'c' } ` + $result = Invoke-WarmupDispatch -Parameters @{ containerName = 'c' } ` -Pending @('Only') -AppIdByName @{ Only = 'id-Only' } ` -Tenants @('default', 'tenant2') -ScriptPath 'unused.ps1' -TestType 'Legacy' -State $state $result | Should -Be @('Only') From 1ec81c63f6b789414e0430c77b42bb39e991ef19 Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Fri, 14 Aug 2026 13:43:18 +0200 Subject: [PATCH 3/4] Warm up Bucket1 with a small app instead of Tests-Local Timing analysis of the PR build showed Tests-Local runs ~17-19 min, so using it as the serial warmup app added that much to Bucket1's critical path. Lead with Tests-Resource (~3 min) instead and move Tests-Local back into its longest-first position. Bucket2's warmup (Tests-Upgrade, ~1.6 min) was already small. --- build/scripts/TestConfiguration.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build/scripts/TestConfiguration.json b/build/scripts/TestConfiguration.json index cfe627d2ebc..574045bb454 100644 --- a/build/scripts/TestConfiguration.json +++ b/build/scripts/TestConfiguration.json @@ -1,6 +1,6 @@ { "LegacyTests-Bucket1": [ - "Tests-Local", + "Tests-Resource", "Tests-Workflow", "AlCosting", "Tests-ERM-Application", @@ -11,6 +11,7 @@ "Tests-SINGLESERVER", "Tests-Job", "Tests-ERM-Purchase", + "Tests-Local", "Tests-ERM-Sales", "Tests-ERM-Finance", "Tests-Dimension", @@ -23,7 +24,6 @@ "Tests-Cost Accounting", "Tests-User", "Tests-Cash Flow", - "Tests-Resource", "Tests-Azure AI" ], "LegacyTests-Bucket2": [ From a12090d6d5cf1ad94b9b7bcc7598b8ec3d1b7b62 Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Mon, 17 Aug 2026 10:45:29 +0200 Subject: [PATCH 4/4] Add Invoke-WarmupDispatch coverage for skip and re-queue paths Add unit tests for the remaining-apps return value, the unresolved-appId skip branch, and the transient warmup failure landing in State.transient for the caller to re-queue. --- .../tests/ParallelTestExecution.Test.ps1 | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/build/scripts/tests/ParallelTestExecution.Test.ps1 b/build/scripts/tests/ParallelTestExecution.Test.ps1 index bc84fd954c3..09706789dee 100644 --- a/build/scripts/tests/ParallelTestExecution.Test.ps1 +++ b/build/scripts/tests/ParallelTestExecution.Test.ps1 @@ -224,4 +224,32 @@ Describe "ParallelTestExecution warmup dispatch" { Should -Invoke Start-TestAppDispatch -Times 0 } } + + It "warms up the first app and returns the remaining apps" { + InModuleScope ParallelTestExecution { + Mock Start-TestAppDispatch { } + Mock Wait-ForAllTestJobs { $true } + $state = [PSCustomObject]@{ jobs = @(); hasFailures = $false; transient = @(); retried = @{} } + $result = Invoke-WarmupDispatch -Parameters @{ containerName = 'c' } ` + -Pending @('A', 'B', 'C') -AppIdByName @{ A = 'id-A'; B = 'id-B'; C = 'id-C' } ` + -Tenants @('default', 'tenant2') -ScriptPath 'unused.ps1' -TestType 'Legacy' -State $state + $result | Should -Be @('B', 'C') + Should -Invoke Start-TestAppDispatch -Times 1 + } + } + + It "leaves a transient warmup failure in State.transient for the caller to re-queue" { + InModuleScope ParallelTestExecution { + Mock Start-TestAppDispatch { } + # Simulate Wait-ForAllTestJobs classifying the warmup app as a transient race. + Mock Wait-ForAllTestJobs { $State.transient = @('A'); $true } + $state = [PSCustomObject]@{ jobs = @(); hasFailures = $false; transient = @(); retried = @{} } + $result = Invoke-WarmupDispatch -Parameters @{ containerName = 'c' } ` + -Pending @('A', 'B', 'C') -AppIdByName @{ A = 'id-A'; B = 'id-B'; C = 'id-C' } ` + -Tenants @('default', 'tenant2') -ScriptPath 'unused.ps1' -TestType 'Legacy' -State $state + $result | Should -Be @('B', 'C') + $state.transient | Should -Contain 'A' + $state.hasFailures | Should -BeFalse + } + } }