/* ======================================================================== * Copyright (c) 2005-2026 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/ * ======================================================================*/ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using Opc.Ua.Di.Server.Builders; using Opc.Ua.Di.Server.SoftwareUpdate; namespace Opc.Ua.Di.Tests { /// /// Verifies the method preconditions and transitions of the /// PrepareForUpdate (OPC 10000-100 §8.4.8) and Installation (§8.4.9) /// state machines of a device's software update. /// [TestFixture] [Category("DI")] [Category("DeviceBuilder")] [Category("SoftwareUpdate")] public sealed class SoftwareUpdateMethodTransitionTests { private static readonly TimeSpan s_timeout = TimeSpan.FromSeconds(30); private DiServerFixture m_fixture = null!; private ISoftwarePackageStore m_store = null!; [OneTimeSetUp] public async Task SetUpAsync() { m_fixture = new DiServerFixture(); await m_fixture.StartAsync().ConfigureAwait(false); m_store = new MemoryPackageStore(); } [OneTimeTearDown] public async Task TearDownAsync() { await m_fixture.DisposeAsync().ConfigureAwait(false); } [Test] public async Task PrepareIsRejectedUnlessIdleAsync() { int prepareCalls = 0; SoftwareUpdateNodes nodes = await CreateAsync("PrepareTwice", su => su.OnPrepare((_, _) => { Interlocked.Increment(ref prepareCalls); return default; })).ConfigureAwait(false); ServiceResult first = await CallAsync(nodes.Prepare.Prepare, nodes).ConfigureAwait(false); ServiceResult second = await CallAsync(nodes.Prepare.Prepare, nodes).ConfigureAwait(false); Assert.That(first, Is.EqualTo(ServiceResult.Good)); Assert.That(second.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); Assert.That(Volatile.Read(ref prepareCalls), Is.EqualTo(1)); Assert.That(nodes.Prepare.CurrentState!.Value.Text, Is.EqualTo("PreparedForUpdate")); } [Test] public async Task AbortCancelsTheRunningPreparationAsync() { var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); int cancelled = 0; var phases = new ConcurrentQueue(); SoftwareUpdateNodes nodes = await CreateAsync("PrepareAbort", su => su .OnPrepare(async (_, ct) => { started.TrySetResult(true); try { await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); } catch (OperationCanceledException) { Interlocked.Exchange(ref cancelled, 1); throw; } }) .OnPrepareStateChanged((_, change) => { phases.Enqueue(change.Phase); return default; })).ConfigureAwait(false); Task prepare = CallAsync(nodes.Prepare.Prepare, nodes); Assert.That(await CompletesAsync(started.Task).ConfigureAwait(false), Is.True); Assert.That(nodes.Prepare.CurrentState!.Value.Text, Is.EqualTo("Preparing")); ServiceResult abort = await CallAsync(nodes.Prepare.Abort, nodes).ConfigureAwait(false); Assert.That(abort, Is.EqualTo(ServiceResult.Good)); Assert.That(await CompletesAsync(prepare).ConfigureAwait(false), Is.True); Assert.That(Volatile.Read(ref cancelled), Is.EqualTo(1)); Assert.That((await prepare.ConfigureAwait(false)).StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); Assert.That(nodes.Prepare.CurrentState!.Value.Text, Is.EqualTo("Idle")); Assert.That(nodes.Prepare.LastTransition!.Value.Text, Is.EqualTo("PreparingToIdle")); Assert.That(phases, Is.EqualTo(new[] { SoftwareUpdatePhase.Started, SoftwareUpdatePhase.Failed })); } [Test] public async Task AHandlerStillRunningAfterAbortCanUseItsTokenAsync() { var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); bool cancellationSeen = false; Exception? tokenError = null; SoftwareUpdateNodes nodes = await CreateAsync("PrepareAbortLate", su => su .OnPrepare(async (_, ct) => { started.TrySetResult(true); await release.Task.ConfigureAwait(false); try { cancellationSeen = ct.WaitHandle.WaitOne(0); } catch (ObjectDisposedException ex) { tokenError = ex; } })).ConfigureAwait(false); Task prepare = CallAsync(nodes.Prepare.Prepare, nodes); Assert.That(await CompletesAsync(started.Task).ConfigureAwait(false), Is.True); ServiceResult abort; try { abort = await CallAsync(nodes.Prepare.Abort, nodes).ConfigureAwait(false); } finally { release.TrySetResult(true); } Assert.That(await CompletesAsync(prepare).ConfigureAwait(false), Is.True); ServiceResult prepared = await prepare.ConfigureAwait(false); Assert.That(abort, Is.EqualTo(ServiceResult.Good)); Assert.That(tokenError, Is.Null, "The token source must outlive the running handler."); Assert.That(cancellationSeen, Is.True); Assert.That(prepared.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); Assert.That(nodes.Prepare.CurrentState!.Value.Text, Is.EqualTo("Idle")); } [Test] public async Task AbortIsRejectedWhenNothingIsPreparingAsync() { SoftwareUpdateNodes nodes = await CreateAsync("AbortIdle").ConfigureAwait(false); ServiceResult abort = await CallAsync(nodes.Prepare.Abort, nodes).ConfigureAwait(false); Assert.That(abort.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); Assert.That(nodes.Prepare.CurrentState!.Value.Text, Is.EqualTo("Idle")); } [Test] public async Task ResumeReturnsAPreparedDeviceToIdleAsync() { int prepareCalls = 0; var phases = new ConcurrentQueue(); SoftwareUpdateNodes nodes = await CreateAsync("PrepareResume", su => su .OnPrepare((_, _) => { Interlocked.Increment(ref prepareCalls); return default; }) .OnPrepareStateChanged((_, change) => { phases.Enqueue(change.Phase); return default; })).ConfigureAwait(false); Assert.That( await CallAsync(nodes.Prepare.Prepare, nodes).ConfigureAwait(false), Is.EqualTo(ServiceResult.Good)); ServiceResult resume = await CallAsync(nodes.Prepare.Resume, nodes).ConfigureAwait(false); Assert.That(resume, Is.EqualTo(ServiceResult.Good)); Assert.That(Volatile.Read(ref prepareCalls), Is.EqualTo(1), "Resume must not prepare again."); Assert.That(nodes.Prepare.CurrentState!.Value.Text, Is.EqualTo("Idle")); Assert.That(nodes.Prepare.LastTransition!.Value.Text, Is.EqualTo("ResumingToIdle")); Assert.That(phases, Is.EqualTo(new[] { SoftwareUpdatePhase.Started, SoftwareUpdatePhase.Completed, SoftwareUpdatePhase.Started, SoftwareUpdatePhase.Completed })); } [Test] public async Task ResumeIsRejectedUnlessPreparedForUpdateAsync() { int prepareCalls = 0; SoftwareUpdateNodes nodes = await CreateAsync("ResumeIdle", su => su.OnPrepare((_, _) => { Interlocked.Increment(ref prepareCalls); return default; })).ConfigureAwait(false); ServiceResult resume = await CallAsync(nodes.Prepare.Resume, nodes).ConfigureAwait(false); Assert.That(resume.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); Assert.That(Volatile.Read(ref prepareCalls), Is.Zero); Assert.That(nodes.Prepare.CurrentState!.Value.Text, Is.EqualTo("Idle")); } [Test] public async Task ResumeIsRejectedWhileInstallingAsync() { var installing = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); SoftwareUpdateNodes nodes = await CreateAsync("ResumeInstalling", su => su .OnInstall(async (_, _, _) => { installing.TrySetResult(true); await release.Task.ConfigureAwait(false); })).ConfigureAwait(false); Assert.That( await CallAsync(nodes.Prepare.Prepare, nodes).ConfigureAwait(false), Is.EqualTo(ServiceResult.Good)); Task install = CallAsync( nodes.Installation.InstallSoftwarePackage, nodes, InstallInputs()); Assert.That(await CompletesAsync(installing.Task).ConfigureAwait(false), Is.True); ServiceResult resume; try { resume = await CallAsync(nodes.Prepare.Resume, nodes).ConfigureAwait(false); } finally { release.TrySetResult(true); } Assert.That(resume.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); Assert.That(nodes.Prepare.CurrentState!.Value.Text, Is.EqualTo("PreparedForUpdate")); Assert.That(await CompletesAsync(install).ConfigureAwait(false), Is.True); Assert.That(await install.ConfigureAwait(false), Is.EqualTo(ServiceResult.Good)); } [Test] public async Task AnInstallationCannotStartWhileResumeIsMovingThePreparationAsync() { var installing = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); SoftwareUpdateNodes nodes = await CreateAsync("ResumeRacesInstall", su => su .OnInstall((_, _, _) => { installing.TrySetResult(true); return default; })).ConfigureAwait(false); Assert.That( await CallAsync(nodes.Prepare.Prepare, nodes).ConfigureAwait(false), Is.EqualTo(ServiceResult.Good)); // Resume has passed its "no installation running" check when it // writes the move to Resuming. Start an installation from that // very moment and see whether it gets to run before Resume is done. Task? install = null; bool installRanDuringResume = false; int started = 0; nodes.Prepare.CurrentState!.OnStateChanged = (_, _, _) => { if (Interlocked.Exchange(ref started, 1) == 0) { install = Task.Run(() => CallAsync( nodes.Installation.InstallSoftwarePackage, nodes, InstallInputs())); installRanDuringResume = installing.Task.Wait(TimeSpan.FromMilliseconds(500)); } }; ServiceResult resume = await CallAsync(nodes.Prepare.Resume, nodes).ConfigureAwait(false); nodes.Prepare.CurrentState.OnStateChanged = null; Assert.That(resume, Is.EqualTo(ServiceResult.Good)); Assert.That(install, Is.Not.Null); Assert.That( installRanDuringResume, Is.False, "An installation must not start between the check of Resume and its move."); Assert.That(await CompletesAsync(install!).ConfigureAwait(false), Is.True); Assert.That(await install!.ConfigureAwait(false), Is.EqualTo(ServiceResult.Good)); Assert.That(nodes.Installation.CurrentState!.Value.Text, Is.EqualTo("Idle")); } [Test] public async Task AnInstallationIsRejectedUnlessIdleAsync() { var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var installing = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); int installCalls = 0; SoftwareUpdateNodes nodes = await CreateAsync("InstallTwice", su => su .OnInstall(async (_, _, _) => { Interlocked.Increment(ref installCalls); installing.TrySetResult(true); await release.Task.ConfigureAwait(false); })).ConfigureAwait(false); Task first = CallAsync( nodes.Installation.InstallSoftwarePackage, nodes, InstallInputs()); Assert.That(await CompletesAsync(installing.Task).ConfigureAwait(false), Is.True); Task secondCall = CallAsync( nodes.Installation.InstallSoftwarePackage, nodes, InstallInputs()); bool secondRejected; try { secondRejected = await Task.WhenAny(secondCall, Task.Delay(TimeSpan.FromSeconds(5))) .ConfigureAwait(false) == secondCall; } finally { release.TrySetResult(true); } Assert.That(secondRejected, Is.True, "The second installation must be rejected, not run."); ServiceResult second = await secondCall.ConfigureAwait(false); Assert.That(second.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); Assert.That(await first.ConfigureAwait(false), Is.EqualTo(ServiceResult.Good)); Assert.That(Volatile.Read(ref installCalls), Is.EqualTo(1)); Assert.That(nodes.Installation.CurrentState!.Value.Text, Is.EqualTo("Idle")); } [Test] public async Task InstallationResumeLeavesErrorForIdleAsync() { SoftwareUpdateNodes nodes = await CreateAsync("InstallResume", su => su .OnInstall((_, _, _) => throw new InvalidOperationException("flash failed"))) .ConfigureAwait(false); ServiceResult install = await CallAsync( nodes.Installation.InstallSoftwarePackage, nodes, InstallInputs()).ConfigureAwait(false); Assert.That(ServiceResult.IsBad(install), Is.True); Assert.That(nodes.Installation.CurrentState!.Value.Text, Is.EqualTo("Error")); ServiceResult resume = await CallAsync(nodes.Installation.Resume, nodes).ConfigureAwait(false); Assert.That(resume, Is.EqualTo(ServiceResult.Good)); Assert.That(nodes.Installation.CurrentState!.Value.Text, Is.EqualTo("Idle")); Assert.That(nodes.Installation.LastTransition!.Value.Text, Is.EqualTo("ErrorToIdle")); } [Test] public async Task InstallationResumeIsRejectedOutsideErrorAsync() { SoftwareUpdateNodes nodes = await CreateAsync("InstallResumeIdle").ConfigureAwait(false); ServiceResult resume = await CallAsync(nodes.Installation.Resume, nodes).ConfigureAwait(false); Assert.That(resume.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); Assert.That(nodes.Installation.CurrentState!.Value.Text, Is.EqualTo("Idle")); } private async Task CreateAsync( string deviceName, Action? configure = null) { IDeviceBuilder builder = await m_fixture.Manager .CreateDeviceAsync(new QualifiedName(deviceName, m_fixture.Manager.DiNamespaceIndex)) .ConfigureAwait(false); builder.WithSoftwareUpdate(m_store, configure); ushort diNs = m_fixture.Manager.DiNamespaceIndex; ISystemContext context = m_fixture.Manager.SystemContext; NodeState softwareUpdate = builder.Device.FindChild( context, new QualifiedName("SoftwareUpdate", diNs))!; return new SoftwareUpdateNodes( softwareUpdate.NodeId, (PrepareForUpdateStateMachineState)softwareUpdate.FindChild( context, new QualifiedName("PrepareForUpdate", diNs))!, (InstallationStateMachineState)softwareUpdate.FindChild( context, new QualifiedName("Installation", diNs))!); } private static ArrayOf InstallInputs() { return new ArrayOf(new[] { new Variant("urn:acme:firmware"), new Variant("9.9.9"), new Variant(Array.Empty()), new Variant(Array.Empty()) }); } private static async Task CompletesAsync(Task task) { return await Task.WhenAny(task, Task.Delay(s_timeout)).ConfigureAwait(false) == task; } private static async Task CallAsync( MethodState? method, SoftwareUpdateNodes nodes, ArrayOf inputs = default) { Assert.That(method, Is.Not.Null, "the method is materialised"); var outputs = new List(); if (method!.OnCallMethod2Async is not null) { return await method.OnCallMethod2Async( null!, method, nodes.SoftwareUpdateId, inputs, outputs, CancellationToken.None).ConfigureAwait(false); } if (method.OnCallMethod2 is not null) { return method.OnCallMethod2(null!, method, nodes.SoftwareUpdateId, inputs, outputs); } return new ServiceResult(StatusCodes.BadNotImplemented); } private sealed record SoftwareUpdateNodes( NodeId SoftwareUpdateId, PrepareForUpdateStateMachineState Prepare, InstallationStateMachineState Installation); } }