mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-29 07:07:24 +08:00
# 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.
367 lines
12 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|
|
}
|