From 9ceb32b22b47af3285bd5ce2f1cf0198a8419510 Mon Sep 17 00:00:00 2001 From: Rhys Bevilaqua Date: Mon, 7 Sep 2026 14:47:02 +0800 Subject: [PATCH] Move parallel test assembly execution into run-tests-action --- .github/workflows/ci.yml | 6 +- src/TestHelper/PortUtility.cs | 43 +++++++--- tools/run-tests.ps1 | 147 --------------------------------- tools/select-test-projects.ps1 | 2 +- 4 files changed, 35 insertions(+), 163 deletions(-) delete mode 100644 tools/run-tests.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ffcbe405b..985488d984 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -182,9 +182,11 @@ jobs: azure-credentials: ${{ secrets.AZURE_ACI_CREDENTIALS }} tag: ServiceControl - name: Run tests - run: ./tools/run-tests.ps1 -Projects $Env:TEST_PROJECTS -MaxParallel ${{ matrix.max-parallel || 1 }} + uses: Particular/run-tests-action@e49b48c5d8f05ce781825e9a50bcdf8c7dd56ba4 + with: + projects: ${{ steps.select.outputs.test-projects }} + max-parallel: ${{ matrix.max-parallel || 1 }} env: - TEST_PROJECTS: ${{ steps.select.outputs.test-projects }} ServiceControl_TESTS_FILTER: ${{ matrix.test-category }} PARTICULARSOFTWARE_LICENSE: ${{ secrets.LICENSETEXT }} AZURE_ACI_CREDENTIALS: ${{ secrets.AZURE_ACI_CREDENTIALS }} diff --git a/src/TestHelper/PortUtility.cs b/src/TestHelper/PortUtility.cs index 21c1aa4169..48a3fd5ead 100644 --- a/src/TestHelper/PortUtility.cs +++ b/src/TestHelper/PortUtility.cs @@ -1,4 +1,4 @@ -namespace TestHelper +namespace TestHelper { using System; using System.Globalization; @@ -8,25 +8,42 @@ public static class PortUtility { /// - /// The port an embedded server should bind, when the test runner has assigned one. + /// The 0-based index that Particular/run-tests-action assigns to each spawned + /// dotnet test process immediately before spawning it, so concurrent runs can derive + /// distinct per-run resources from it. The value is unique across all runs in the invocation; + /// in sequential mode (max-parallel == 1) it is always 0. Defaults to 0 + /// when unset (e.g. local development outside the action). /// - public const string AssignedPortVariableName = "ServiceControl_TESTS_RAVENDB_PORT"; + public const string ParallelIndexVariableName = "PARTICULAR_RUN_TESTS_ACTION_PARALLEL_INDEX"; /// - /// Returns the port assigned by the test runner, or probes for a free one when running alone. + /// Spacing between per-run ports. Wide enough that a run's embedded server has room for any + /// additional listeners it opens alongside its main port. + /// + public const int ParallelPortSpacing = 10; + + /// + /// Returns the port derived from the run-tests-action per-run parallel index. /// /// - /// Concurrent test processes cannot each probe: only inspects - /// the listeners active at that instant, so processes starting together all see the same port - /// free and all but one then fail to bind. + /// + /// The action sets on every spawned dotnet test + /// process. Each run's port is computed as startPort + (index * ), + /// so concurrent runs bind distinct ports (the historic spacing of 10 is preserved). A sequential + /// run (index 0) lands on -- the same base the historic probe + /// started from, so non-parallel behavior is unchanged. + /// + /// + /// When the index is unset (local development outside the action) it defaults to 0 and the + /// base is used directly. remains + /// available for callers that want to probe for a free port rather than derive a fixed one. + /// /// public static int GetAssignedOrAvailablePort(int startPort) { - var assignedPort = Environment.GetEnvironmentVariable(AssignedPortVariableName); - - return string.IsNullOrWhiteSpace(assignedPort) - ? FindAvailablePort(startPort) - : int.Parse(assignedPort, CultureInfo.InvariantCulture); + var indexText = Environment.GetEnvironmentVariable(ParallelIndexVariableName); + var index = int.TryParse(indexText?.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var i) ? i : 0; + return startPort + (Math.Max(0, index) * ParallelPortSpacing); } public static int FindAvailablePort(int startPort) @@ -47,4 +64,4 @@ public static int FindAvailablePort(int startPort) return startPort; } } -} +} \ No newline at end of file diff --git a/tools/run-tests.ps1 b/tools/run-tests.ps1 deleted file mode 100644 index 5d47ac6085..0000000000 --- a/tools/run-tests.ps1 +++ /dev/null @@ -1,147 +0,0 @@ -# Runs dotnet test against an explicit list of test projects, rather than discovering every test -# project under src. The list comes from tools/select-test-projects.ps1, so a job only pays for the -# assemblies belonging to its test category. -# -# This is a scoped replacement for Particular/run-tests-action, which has no way to be told which -# projects to run. It should fold back into that action once it grows a 'projects' input. -# -# -MaxParallel runs several assemblies at once. CI jobs that merge categories sharing infrastructure -# use it so the job costs the slowest assembly rather than the sum of all of them. Output is buffered -# per run and replayed on completion, because interleaved dotnet test output is unreadable. - -param( - [Parameter(Mandatory)] - [string]$Projects, - - [string]$TargetPlatform = 'x64', - - [ValidateRange(1, 16)] - [int]$MaxParallel = 1, - - [switch]$ReportWarnings -) - -$ErrorActionPreference = 'Stop' - -$projectPaths = $Projects -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ } - -if ($projectPaths.Count -eq 0) { - throw 'No test projects were supplied.' -} - -Write-Output "Target Platform = $TargetPlatform" -Write-Output "Max parallel test runs = $MaxParallel" - -$reportWarningsValue = if ($ReportWarnings) { 'true' } else { 'false' } -$isUnix = $PSVersionTable.Platform -eq 'Unix' - -$runs = [Collections.Generic.List[object]]::new() - -foreach ($project in $projectPaths) { - $frameworks = @( - (Select-Xml -Path $project -XPath "/Project/PropertyGroup/TargetFramework").Node.InnerText - (Select-Xml -Path $project -XPath "/Project/PropertyGroup/TargetFrameworks").Node.InnerText -split ';' - ) | Where-Object { $_ } - - if ($frameworks.Count -eq 0) { - throw "Could not determine a target framework for $project." - } - - foreach ($framework in $frameworks) { - if ($isUnix -and ($framework.StartsWith('net4') -or $framework.Contains('-windows'))) { - Write-Output "Skipping $(Split-Path $project -Leaf) ($framework) because it cannot run on this platform." - continue - } - - $runs.Add([pscustomobject]@{ - Label = "$(Split-Path $project -Leaf) ($framework)" - Project = $project - Framework = $framework - }) - } -} - -if ($runs.Count -eq 0) { - throw 'No test projects were runnable on this platform.' -} - -# RavenDB.Embedded binds a fixed port, and the tests otherwise pick one by probing the active -# listeners, which concurrent processes all do at the same instant and all resolve to the same port. -# Hand each run its own instead. Left unset when running one at a time, because probing copes better -# with a port that something else on the machine already holds. -$assignPorts = $MaxParallel -gt 1 -$nextPort = 33334 -$portSpacing = 10 - -$exitCode = 0 - -function Complete-Run($run) { - Write-Output "::group::Running $($run.Label)" - foreach ($stream in @($run.OutFile, $run.ErrFile)) { - if ((Test-Path $stream) -and (Get-Item $stream).Length -gt 0) { - Get-Content -Path $stream | Write-Output - } - Remove-Item -Path $stream -Force -ErrorAction SilentlyContinue - } - Write-Output '::endgroup::' - - if ($run.Process.ExitCode -ne 0) { - Write-Output "::error::$($run.Label) exit code = $($run.Process.ExitCode)" - $script:exitCode = 1 - } -} - -$pending = [Collections.Generic.Queue[object]]::new($runs) -$active = [Collections.Generic.List[object]]::new() - -while ($pending.Count -gt 0 -or $active.Count -gt 0) { - while ($active.Count -lt $MaxParallel -and $pending.Count -gt 0) { - $run = $pending.Dequeue() - $run | Add-Member -NotePropertyName OutFile -NotePropertyValue ([IO.Path]::GetTempFileName()) - $run | Add-Member -NotePropertyName ErrFile -NotePropertyValue ([IO.Path]::GetTempFileName()) - - $arguments = @( - 'test', $run.Project - '--configuration', 'Release' - '--no-build' - '--framework', $run.Framework - '--logger', "GitHubActions;report-warnings=$reportWarningsValue" - '--' - 'RunConfiguration.TreatNoTestsAsError=true' - "RunConfiguration.TargetPlatform=$TargetPlatform" - ) - - if ($assignPorts) { - # Set immediately before spawning, so the child inherits this run's value. Safe because - # spawning is serialised here even though the runs themselves are not. - $Env:ServiceControl_TESTS_RAVENDB_PORT = $nextPort - $nextPort += $portSpacing - } - - Write-Output "Starting $($run.Label)" - - $run | Add-Member -NotePropertyName Process -NotePropertyValue ( - Start-Process -FilePath 'dotnet' -ArgumentList $arguments -NoNewWindow -PassThru ` - -RedirectStandardOutput $run.OutFile -RedirectStandardError $run.ErrFile) - $active.Add($run) - } - - $finished = $active | Where-Object { $_.Process.HasExited } - - if (-not $finished) { - Start-Sleep -Milliseconds 500 - continue - } - - foreach ($run in @($finished)) { - # Bounded on purpose. The parameterless WaitForExit() also waits for the redirected streams to - # reach EOF, and on Linux Start-Process pumps them through a pipe, so a test that leaves behind - # a child holding the inherited handle blocks it forever. HasExited has already told us the - # test process itself is done; this only gives the pump a moment to drain. - [void]$run.Process.WaitForExit(5000) - Complete-Run $run - [void]$active.Remove($run) - } -} - -exit $exitCode diff --git a/tools/select-test-projects.ps1 b/tools/select-test-projects.ps1 index 8ae2dc89aa..8e36542800 100644 --- a/tools/select-test-projects.ps1 +++ b/tools/select-test-projects.ps1 @@ -7,7 +7,7 @@ # # A category can span several test projects that share infrastructure, so that CI provisions its # container once and compiles the union of their closures once. Their assemblies then run concurrently, -# via the -MaxParallel switch on run-tests.ps1. +# via the 'max-parallel' input on Particular/run-tests-action. # # Use -List to print every category and its projects without writing any files.