Files
Marc Schier 4d613d11dc CI: migrate to netstandard Managed DevOps Pool + per-TFM build matrix (#3866)
# Description

End-to-end migration of the Azure DevOps CI pipelines to the
`netstandard` Managed DevOps Pool with the build matrix re-shaped to fit
the new agents and a handful of test/project fixes that fell out of the
migration.

**Pipeline routing & defaults**

- `azure-pipelines.yml` and `azure-pipelines-preview.yml` default
`managedPool=true`, routing Windows/Linux jobs to the `netstandard`
Managed DevOps Pool with `ImageOverride` demands; flipping the
queue-time boolean to `false` falls back to Microsoft-hosted agents.
- Per-platform images come from the root yml (single source of truth):
- Managed pool: `windows-2022-g2` / `ubuntu-22.04-g2` (Mac stays hosted:
`macOS-15`).
  - Hosted: `windows-2025-vs2026` / `ubuntu-24.04` / `macOS-15`.
- `.azurepipelines/test.yml` and `test-aot.yml` declare `hosted` as a
typed `boolean` parameter so `not(parameters.hosted)` actually evaluates
correctly (an untyped value left it as the string `'false'`, which is
truthy and silently routed managed-pool jobs to Microsoft-hosted
`vmImage` demands).
- macOS test jobs always run on the Microsoft-hosted `macOS-15` image
regardless of `managedPool`; the four per-framework test stages queue a
dedicated `hosted: true` Mac invocation unconditionally and the main
matrix carries only Windows+Linux.
- Test-template prep jobs (which only run a PowerShell matrix script)
run on the managed pool whenever a `poolName` is set, even when the
matrix `hosted: true`, so the Microsoft-hosted parallel-job allotment
isn't blocked.

**Build stage — per-(TFM × configuration) matrix**

- `.azurepipelines/get-matrix.ps1` gained a `-Tfms` parameter that fans
the matrix across target frameworks alongside the existing agent /
configuration dimensions and emits a per-entry `targetTfm`.
- `.azurepipelines/sln.yml` accepts a `tfms` parameter and threads
`$(targetTfm)` into both `dotnet restore` and `dotnet build` as
`/p:CustomTestTarget=$(targetTfm)` so each matrix job builds a single
TFM and fits on the Managed DevOps Pool's small (~16 GB) temp disk.
- `azure-pipelines.yml` `build` stage passes `tfms: 'net10.0,net48'` for
PR builds and the full
`net472,net48,netstandard2.0,netstandard2.1,net8.0,net9.0,net10.0` set
for master / scheduled / manual / fork-PR runs.
- `sln.yml`'s restore step uses `command: custom` (`custom: restore`)
because `command: restore` silently drops the `arguments` input —
without it `/p:CustomTestTarget` never reached restore and
`obj/project.assets.json` was populated with the wrong TFM set.
- `sln.yml` no longer uses the `MSBuild@1` task; it now uses
`DotNetCoreCLI@2` with `dotnet build` so the .NET 10 SDK's bundled
MSBuild (which understands `.slnx`) is used instead of Visual Studio
2022's MSBuild on the managed-pool image.

**Project-level fixes that fell out of the matrix split**

- `Applications/McpServer`, `Applications/MinimalBoilerServer`,
`Applications/MinimalCalcServer`, and `Tests/Opc.Ua.Aot.Tests` each get
a `Directory.Build.targets` that turns the project into a no-op (cleared
`Compile` / `ProjectReference` / `PackageReference` / etc.,
`OutputType=Library`) when `CustomTestTarget` restricts the matrix to a
legacy TFM (`net4*` / `netstandard2.*`) the project can't target. Target
overrides are deliberately avoided — same-named targets in
`Directory.Build.targets` ALWAYS replace the SDK definitions, which
would silently break `dotnet publish` / `dotnet test` in the
unrestricted GitHub Actions flows.
- `Tools/Opc.Ua.MigrationAnalyzer.csproj`'s `ProjectReference` to
`Opc.Ua.MigrationAnalyzer.Core` no longer hard-pins
`SetTargetFramework=net10.0`; the pin follows `CustomTestTarget` (and
falls back to `net10.0` when unset).

**Test reliability**

-
`Tests/Opc.Ua.PubSub.Tests/IntervalRunnerTests.FakeTimeProviderDrivesDeterministicSchedulingAsync`
no longer calls a single `fake.Advance(300)` expecting three timers to
fire — it advances one 100 ms interval at a time and waits for each fire
(the runner only registers the next `Delay` after the previous callback
resumes on the thread pool; on slower CI agents the race surfaces
consistently).
- `Tests/Opc.Ua.Server.Tests/Fluent/SimulationBuilderExtensionsTests`
(four "expect ≥ N ticks in 150 ms" tests) replace fixed
`Task.Delay`/`Thread.Sleep` waits with a `WaitForAsync` polling helper,
eliminating thread-pool cold-start flakiness on slow agents (observed on
the hosted `macOS-15` runner).

## Related Issues

_No tracking issue — pipeline tuning to standardize on the netstandard
Managed DevOps Pool, plus the test/project follow-ups required for the
matrix to be green on that pool._

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage. _(N/A — pipeline +
test-reliability fixes; the two test fixes themselves were verified to
pass 5× in stress runs locally.)_
- [x] I have added all necessary documentation. _(Comments inline in the
new `Directory.Build.targets`, `sln.yml`, and `azure-pipelines.yml`
explain the unusual MSBuild/`DotNetCoreCLI@2` interactions for future
maintainers.)_
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [x] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. _(Verified
`dotnet build UA.slnx -p:CustomTestTarget=net10.0 -c Release` and
`-p:CustomTestTarget=net48 -c Debug` both complete with 0 errors;
targeted test runs for the two modified test files pass 5/5.)_
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-06-10 17:19:41 +02:00

367 lines
12 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
// CA2000: test code; many disposables are ownership-transferred to test fixtures or short-lived,
// making CA2000 noisy without a real leak risk. Disabled file-level for the suite.
#pragma warning disable CA2000
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Time.Testing;
using NUnit.Framework;
using Opc.Ua.Tests;
namespace Opc.Ua.PubSub.Tests
{
[TestFixture]
[Category("IntervalRunner")]
[SetCulture("en-us")]
[SetUICulture("en-us")]
public class IntervalRunnerTests
{
private ITelemetryContext m_telemetry;
[OneTimeSetUp]
public void OneTimeSetUp()
{
m_telemetry = NUnitTelemetryContext.Create();
}
[Test]
public void ConstructorSetsProperties()
{
object id = "runner1";
static bool canExecute() => true;
static Task action() => Task.CompletedTask;
using var runner = new IntervalRunner(id, 100, canExecute, action, m_telemetry);
Assert.That(runner.Id, Is.EqualTo("runner1"));
Assert.That(runner.Interval, Is.EqualTo(100));
// Cast to object to suppress NUnit's auto-invocation of
// Func<T> actuals — we want reference equality on the
// delegate, not the bool the delegate returns.
Assert.That((object)runner.CanExecuteFunc, Is.SameAs(canExecute));
Assert.That((object)runner.IntervalActionAsync, Is.SameAs(action));
}
[Test]
public void IntervalClampsToMinimumOf10()
{
using var runner = new IntervalRunner(
"runner", 1, () => true, () => Task.CompletedTask, m_telemetry);
Assert.That(runner.Interval, Is.EqualTo(10));
}
[Test]
public void IntervalSetterClampsNegativeToMinimum()
{
using var runner = new IntervalRunner(
"runner", 100, () => true, () => Task.CompletedTask, m_telemetry);
runner.Interval = -5;
Assert.That(runner.Interval, Is.EqualTo(10));
}
[Test]
public void IntervalSetterClampsZeroToMinimum()
{
using var runner = new IntervalRunner(
"runner", 100, () => true, () => Task.CompletedTask, m_telemetry);
runner.Interval = 0;
Assert.That(runner.Interval, Is.EqualTo(10));
}
[Test]
public void IntervalSetterAcceptsValidValue()
{
using var runner = new IntervalRunner(
"runner", 100, () => true, () => Task.CompletedTask, m_telemetry);
runner.Interval = 500;
Assert.That(runner.Interval, Is.EqualTo(500));
}
[Test]
[Explicit] // Too timing-sensitive for regular test runs
public async Task StartExecutesActionAsync()
{
int executionCount = 0;
using var runner = new IntervalRunner(
"runner",
10,
() => true,
() =>
{
Interlocked.Increment(ref executionCount);
return Task.CompletedTask;
},
m_telemetry);
runner.Start();
await Task.Delay(200).ConfigureAwait(false);
runner.Stop();
Assert.That(executionCount, Is.GreaterThan(0));
}
[Test]
[Explicit] // Too timing-sensitive for regular test runs
public async Task StopPreventsSubsequentExecutionAsync()
{
int executionCount = 0;
using var runner = new IntervalRunner(
"runner",
10,
() => true,
() =>
{
Interlocked.Increment(ref executionCount);
return Task.CompletedTask;
},
m_telemetry);
runner.Start();
await Task.Delay(100).ConfigureAwait(false);
runner.Stop();
int countAfterStop = executionCount;
await Task.Delay(100).ConfigureAwait(false);
Assert.That(executionCount, Is.LessThanOrEqualTo(countAfterStop + 1));
}
[Test]
public void StopWithoutStartDoesNotThrow()
{
using var runner = new IntervalRunner(
"runner", 100, () => true, () => Task.CompletedTask, m_telemetry);
Assert.DoesNotThrow(runner.Stop);
}
[Test]
public void DisposeDoesNotThrow()
{
var runner = new IntervalRunner(
"runner", 100, () => true, () => Task.CompletedTask, m_telemetry);
Assert.DoesNotThrow(runner.Dispose);
}
[Test]
public void DisposeAfterStartDoesNotThrow()
{
var runner = new IntervalRunner(
"runner", 100, () => true, () => Task.CompletedTask, m_telemetry);
runner.Start();
Assert.DoesNotThrow(runner.Dispose);
}
[Test]
public void DoubleDisposeDoesNotThrow()
{
var runner = new IntervalRunner(
"runner", 100, () => true, () => Task.CompletedTask, m_telemetry);
runner.Dispose();
Assert.DoesNotThrow(runner.Dispose);
}
[Test]
public async Task CanExecuteFuncFalseSkipsActionAsync()
{
int executionCount = 0;
using var runner = new IntervalRunner(
"runner",
10,
() => false,
() =>
{
Interlocked.Increment(ref executionCount);
return Task.CompletedTask;
},
m_telemetry);
runner.Start();
await Task.Delay(200).ConfigureAwait(false);
runner.Stop();
Assert.That(executionCount, Is.Zero);
}
[Test]
[Explicit] // Too timing-sensitive for regular test runs
public async Task RestartAfterStopIsAllowedAsync()
{
int executionCount = 0;
using var runner = new IntervalRunner(
"runner",
10,
() => true,
() =>
{
Interlocked.Increment(ref executionCount);
return Task.CompletedTask;
},
m_telemetry);
runner.Start();
await Task.Delay(100).ConfigureAwait(false);
runner.Stop();
int countAfterFirstStop = executionCount;
runner.Start();
await Task.Delay(100).ConfigureAwait(false);
runner.Stop();
Assert.That(executionCount, Is.GreaterThan(countAfterFirstStop));
}
[Test]
public void IdAcceptsNullValue()
{
using var runner = new IntervalRunner(
null, 100, () => true, () => Task.CompletedTask, m_telemetry);
Assert.That(runner.Id, Is.Null);
}
[Test]
public void IdAcceptsIntValue()
{
using var runner = new IntervalRunner(
42, 100, () => true, () => Task.CompletedTask, m_telemetry);
Assert.That(runner.Id, Is.EqualTo(42));
}
[Test]
public async Task FakeTimeProviderDrivesDeterministicSchedulingAsync()
{
var fake = new FakeTimeProvider();
int executionCount = 0;
using var runner = new IntervalRunner(
"fake-runner",
100,
() => true,
() =>
{
Interlocked.Increment(ref executionCount);
return Task.CompletedTask;
},
m_telemetry,
fake);
runner.Start();
// The first loop iteration runs synchronously (sleepCycle == 0)
// and queues the action on the thread pool.
await WaitForAsync(() => Volatile.Read(ref executionCount) >= 1)
.ConfigureAwait(false);
int afterStart = Volatile.Read(ref executionCount);
// Advance the fake clock by one interval; the awaited Delay completes
// deterministically and the next action fires.
fake.Advance(TimeSpan.FromMilliseconds(100));
await WaitForAsync(() => Volatile.Read(ref executionCount) >= afterStart + 1)
.ConfigureAwait(false);
// Advance three intervals (one at a time) and wait for the runner
// to register and fire each new Delay; a single Advance(300) would
// race because each subsequent Delay is only registered after the
// previous timer's continuation resumes on the thread pool.
int afterFirstAdvance = Volatile.Read(ref executionCount);
for (int i = 0; i < 3; i++)
{
int before = Volatile.Read(ref executionCount);
fake.Advance(TimeSpan.FromMilliseconds(100));
await WaitForAsync(() => Volatile.Read(ref executionCount) >= before + 1)
.ConfigureAwait(false);
}
Assert.That(
Volatile.Read(ref executionCount),
Is.GreaterThanOrEqualTo(afterFirstAdvance + 3));
runner.Stop();
}
[Test]
public async Task FakeTimeProviderWithoutAdvanceDoesNotExecuteRepeatedlyAsync()
{
var fake = new FakeTimeProvider();
int executionCount = 0;
using var runner = new IntervalRunner(
"fake-runner-no-advance",
100,
() => true,
() =>
{
Interlocked.Increment(ref executionCount);
return Task.CompletedTask;
},
m_telemetry,
fake);
runner.Start();
// The first iteration runs immediately (sleepCycle == 0) and queues an action.
await WaitForAsync(() => Volatile.Read(ref executionCount) >= 1)
.ConfigureAwait(false);
// Without advancing the fake clock, subsequent iterations stay parked in
// Delay; assert the count stays at 1 for a long real-time window.
await Task.Delay(200).ConfigureAwait(false);
runner.Stop();
Assert.That(Volatile.Read(ref executionCount), Is.EqualTo(1));
}
private static async Task WaitForAsync(
Func<bool> condition,
TimeSpan? timeout = null)
{
TimeSpan deadline = timeout ?? TimeSpan.FromSeconds(5);
DateTime end = DateTime.UtcNow + deadline;
while (!condition())
{
if (DateTime.UtcNow > end)
{
Assert.Fail($"Condition not satisfied within {deadline.TotalMilliseconds}ms.");
}
await Task.Delay(5).ConfigureAwait(false);
}
}
}
}