/* ======================================================================== * 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/ * ======================================================================*/ using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Moq; using NUnit.Framework; using Opc.Ua.Server.FileSystem; namespace Opc.Ua.Server.Tests.FileSystem { /// /// Verifies directory capacity admission, refresh publication, and cleanup across concurrent binding operations. /// [TestFixture] [Category("FileSystem")] public sealed class FileDirectoryLifecycleRegressionTests { /// /// Verifies that create, copy, and move operations check destination capacity before mutating the provider. /// [TestCase("file", 2)] [TestCase("directory", 2)] [TestCase("copy", 2)] [TestCase("move", 2)] [TestCase("file", 3)] [TestCase("directory", 3)] [TestCase("copy", 3)] [TestCase("move", 3)] public async Task CapacityAdmissionPrecedesProviderMutationAsync(string operation, int maximum) { using var harness = new BindingHarness(); await harness.Physical.CreateDirectoryAsync("target", CancellationToken.None).ConfigureAwait(false); await harness.Physical.CreateFileAsync("source", CancellationToken.None).ConfigureAwait(false); await harness.Physical.CreateFileAsync("target/a", CancellationToken.None).ConfigureAwait(false); await harness.Physical.CreateFileAsync("target/b", CancellationToken.None).ConfigureAwait(false); await using IFileDirectoryBinding binding = await harness.BindAsync(maximum).ConfigureAwait(false); var host = (IFileSystemHost)binding; FileDirectoryState target = harness.FindDirectory("target"); MethodState method = operation switch { "file" => target.CreateFile!, "directory" => target.CreateDirectory!, _ => harness.Root.MoveOrCopy! }; ArrayOf arguments = operation switch { "file" => ["new", false], "directory" => ["new"], _ => [host.BuildFileNodeId("source"), target.NodeId, operation == "copy", "new"] }; (ServiceResult result, _) = await FileReadRegressionTests.CallAsync( method, harness.Context, method.Parent!.NodeId, arguments).ConfigureAwait(false); bool admitted = maximum == 3; Assert.That(result.StatusCode, Is.EqualTo(admitted ? StatusCodes.Good : StatusCodes.BadEncodingLimitsExceeded)); Assert.That(harness.MutationCount, Is.EqualTo(admitted ? 1 : 0)); FileSystemEntry? created = await harness.Physical.GetEntryAsync("target/new", CancellationToken.None) .ConfigureAwait(false); Assert.That(created.HasValue, Is.EqualTo(admitted)); FileSystemEntry? source = await harness.Physical.GetEntryAsync("source", CancellationToken.None) .ConfigureAwait(false); Assert.That(source.HasValue, Is.EqualTo(!admitted || operation != "move")); } /// /// Verifies that renaming within a full directory succeeds without reserving an additional entry. /// [Test] public async Task RenameWithinFullDirectoryDoesNotConsumeAnotherEntryAsync() { using var harness = new BindingHarness(); await harness.Physical.CreateFileAsync("source", CancellationToken.None).ConfigureAwait(false); await using IFileDirectoryBinding binding = await harness.BindAsync(1).ConfigureAwait(false); var host = (IFileSystemHost)binding; (ServiceResult result, _) = await FileReadRegressionTests.CallAsync( harness.Root.MoveOrCopy!, harness.Context, harness.Root.NodeId, [host.BuildFileNodeId("source"), harness.Root.NodeId, false, "renamed"]).ConfigureAwait(false); Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good)); Assert.That(await harness.Physical.GetEntryAsync("source", CancellationToken.None).ConfigureAwait(false), Is.Null); Assert.That(host.TryGetProviderPath(host.BuildFileNodeId("renamed"), out string path, out _, out _), Is.True); Assert.That(path, Is.EqualTo("renamed")); } /// /// Verifies that post-commit refresh failures are logged without reporting a successful mutation as failed. /// [TestCase("io")] [TestCase("limit")] [TestCase("cancellation")] public async Task CommittedMutationIsNotReportedAsProviderFailureWhenRefreshFailsAsync(string failure) { using var harness = new BindingHarness(); await using IFileDirectoryBinding binding = await harness.BindAsync(2).ConfigureAwait(false); harness.RefreshFailure = failure switch { "io" => new IOException("refresh unavailable"), "limit" => new ServiceResultException(StatusCodes.BadEncodingLimitsExceeded), _ => new OperationCanceledException() }; (ServiceResult result, List output) = await FileReadRegressionTests.CallAsync( harness.Root.CreateFile!, harness.Context, harness.Root.NodeId, ["created", false]).ConfigureAwait(false); Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good)); Assert.That(await harness.Physical.GetEntryAsync("created", CancellationToken.None).ConfigureAwait(false), Is.Not.Null); Assert.That(output[0].TryGetValue(out NodeId returnedId), Is.True); harness.Logger.Verify(logger => logger.Log( LogLevel.Error, It.Is(id => id.Name == "FileDirectoryRefreshFailed"), It.IsAny(), harness.RefreshFailure, It.IsAny>()), Times.Once); harness.RefreshFailure = null; await binding.RefreshAsync().ConfigureAwait(false); Assert.That(((IFileSystemHost)binding).TryGetProviderPath(returnedId, out string path, out _, out _), Is.True); Assert.That(path, Is.EqualTo("created")); } /// /// Verifies that a corrective deletion can restore capacity and publish a fresh lookup after repeated failures. /// [Test] public async Task CorrectiveDeleteRecoversAfterPersistentOverLimitRefreshFailureAsync() { using var harness = new BindingHarness(); await harness.Physical.CreateFileAsync("keep", CancellationToken.None).ConfigureAwait(false); await harness.Physical.CreateFileAsync("delete-me", CancellationToken.None).ConfigureAwait(false); await using IFileDirectoryBinding binding = await harness.BindAsync(2).ConfigureAwait(false); var host = (IFileSystemHost)binding; NodeId originalId = harness.FindFile("keep").NodeId; NodeId deletedId = harness.FindFile("delete-me").NodeId; await harness.Physical.CreateFileAsync("external", CancellationToken.None).ConfigureAwait(false); (ServiceResult renamed, List output) = await FileReadRegressionTests.CallAsync( harness.Root.MoveOrCopy!, harness.Context, harness.Root.NodeId, [originalId, harness.Root.NodeId, false, "renamed"]).ConfigureAwait(false); Assert.That(renamed.StatusCode, Is.EqualTo(StatusCodes.Good)); Assert.That(output[0].TryGetValue(out NodeId renamedId), Is.True); Assert.That(harness.MutationCount, Is.EqualTo(1)); Assert.That(await harness.Physical.GetEntryAsync("keep", CancellationToken.None).ConfigureAwait(false), Is.Null); Assert.That(await harness.Physical.GetEntryAsync("renamed", CancellationToken.None).ConfigureAwait(false), Is.Not.Null); Assert.That(host.TryGetProviderPath(renamedId, out _, out _, out _), Is.False); harness.VerifyRefreshLimitFailures(1); harness.VerifyRefreshCommitState(true, 1); harness.VerifyRefreshCommitState(false, 0); ServiceResultException refreshFailure = Assert.ThrowsAsync( async () => await binding.RefreshAsync().ConfigureAwait(false))!; Assert.That(refreshFailure.StatusCode, Is.EqualTo(StatusCodes.BadEncodingLimitsExceeded)); Assert.That(host.TryGetProviderPath(originalId, out _, out _, out _), Is.True); Assert.That(host.TryGetProviderPath(host.BuildFileNodeId("external"), out _, out _, out _), Is.False); (ServiceResult deleted, _) = await FileReadRegressionTests.CallAsync( harness.Root.DeleteFileSystemObject!, harness.Context, harness.Root.NodeId, [deletedId]) .ConfigureAwait(false); Assert.That(deleted.StatusCode, Is.EqualTo(StatusCodes.Good), "A failed pre-refresh must not block the delete that restores the binding limit."); Assert.That(harness.MutationCount, Is.EqualTo(2)); harness.Provider.Verify(provider => provider.DeleteAsync("delete-me", It.IsAny()), Times.Once); var paths = new List(); await foreach (FileSystemEntry entry in harness.Physical.EnumerateAsync( string.Empty, CancellationToken.None).ConfigureAwait(false)) { paths.Add(entry.Path); } Assert.That(paths, Is.EquivalentTo(s_recoveredPaths)); var children = new List(); harness.Root.GetChildren(harness.Context, children); Assert.That(children.OfType().Select(file => file.BrowseName.Name), Is.EquivalentTo(s_recoveredPaths)); Assert.That(host.TryGetProviderPath(originalId, out _, out _, out _), Is.False); Assert.That(host.TryGetProviderPath(deletedId, out _, out _, out _), Is.False); Assert.That(host.TryGetProviderPath(renamedId, out string path, out _, out _), Is.True); Assert.That(path, Is.EqualTo("renamed")); harness.VerifyRefreshLimitFailures(2); harness.VerifyRefreshCommitState(true, 1); harness.VerifyRefreshCommitState(false, 1); await binding.RefreshAsync().ConfigureAwait(false); harness.VerifyRefreshLimitFailures(2); Assert.That(harness.MutationCount, Is.EqualTo(2)); } /// /// Verifies that repeated refresh failures do not permit a create operation to exceed directory capacity. /// [Test] public async Task PersistentRefreshFailureDoesNotBypassCapacityAdmissionAsync() { using var harness = new BindingHarness(); await harness.Physical.CreateFileAsync("keep", CancellationToken.None).ConfigureAwait(false); await using IFileDirectoryBinding binding = await harness.BindAsync(1).ConfigureAwait(false); NodeId originalId = harness.FindFile("keep").NodeId; await harness.Physical.CreateFileAsync("external", CancellationToken.None).ConfigureAwait(false); (ServiceResult renamed, _) = await FileReadRegressionTests.CallAsync( harness.Root.MoveOrCopy!, harness.Context, harness.Root.NodeId, [originalId, harness.Root.NodeId, false, "renamed"]).ConfigureAwait(false); Assert.That(renamed.StatusCode, Is.EqualTo(StatusCodes.Good)); (ServiceResult created, _) = await FileReadRegressionTests.CallAsync( harness.Root.CreateFile!, harness.Context, harness.Root.NodeId, ["blocked", false]) .ConfigureAwait(false); Assert.That(created.StatusCode, Is.EqualTo(StatusCodes.BadEncodingLimitsExceeded)); Assert.That(harness.MutationCount, Is.EqualTo(1)); harness.Provider.Verify(provider => provider.CreateFileAsync( It.IsAny(), It.IsAny()), Times.Never); Assert.That(await harness.Physical.GetEntryAsync("blocked", CancellationToken.None).ConfigureAwait(false), Is.Null); harness.VerifyRefreshLimitFailures(2); harness.VerifyRefreshCommitState(true, 1); harness.VerifyRefreshCommitState(false, 1); } /// /// Verifies that cancellation thrown by the pre-mutation refresh prevents provider deletion. /// [Test] public async Task PreMutationRefreshCancellationDoesNotReachProviderAsync() { using var harness = new BindingHarness(); await harness.Physical.CreateFileAsync("keep", CancellationToken.None).ConfigureAwait(false); await using IFileDirectoryBinding binding = await harness.BindAsync(2).ConfigureAwait(false); NodeId keptId = harness.FindFile("keep").NodeId; harness.RefreshFailure = new IOException("post-refresh unavailable"); (ServiceResult created, _) = await FileReadRegressionTests.CallAsync( harness.Root.CreateFile!, harness.Context, harness.Root.NodeId, ["created", false]) .ConfigureAwait(false); Assert.That(created.StatusCode, Is.EqualTo(StatusCodes.Good)); harness.RefreshFailure = new OperationCanceledException("pre-refresh cancelled"); Assert.ThrowsAsync(async () => await harness.Root.DeleteFileSystemObject!.OnCallAsync!( harness.Context, harness.Root.DeleteFileSystemObject, harness.Root.NodeId, keptId, CancellationToken.None).ConfigureAwait(false)); Assert.That(harness.MutationCount, Is.EqualTo(1)); harness.Provider.Verify(provider => provider.DeleteAsync( It.IsAny(), It.IsAny()), Times.Never); Assert.That(await harness.Physical.GetEntryAsync("keep", CancellationToken.None).ConfigureAwait(false), Is.Not.Null); harness.VerifyRefreshCommitState(true, 1); harness.VerifyRefreshCommitState(false, 0); } /// /// Verifies that request cancellation during a failing pre-refresh is propagated before provider mutation. /// [Test] public async Task CancellationDuringFailedPreRefreshDoesNotReachProviderAsync() { using var harness = new BindingHarness(); await harness.Physical.CreateFileAsync("keep", CancellationToken.None).ConfigureAwait(false); await using IFileDirectoryBinding binding = await harness.BindAsync(2).ConfigureAwait(false); NodeId keptId = harness.FindFile("keep").NodeId; harness.RefreshFailure = new IOException("refresh unavailable"); (ServiceResult created, _) = await FileReadRegressionTests.CallAsync( harness.Root.CreateFile!, harness.Context, harness.Root.NodeId, ["created", false]) .ConfigureAwait(false); Assert.That(created.StatusCode, Is.EqualTo(StatusCodes.Good)); using var cancellation = new CancellationTokenSource(); harness.BeforeEnumeration = cancellation.Cancel; OperationCanceledException failure = Assert.ThrowsAsync(async () => await harness.Root.DeleteFileSystemObject!.OnCallAsync!( harness.Context, harness.Root.DeleteFileSystemObject, harness.Root.NodeId, keptId, cancellation.Token).ConfigureAwait(false))!; Assert.That(failure.CancellationToken, Is.EqualTo(cancellation.Token)); Assert.That(harness.MutationCount, Is.EqualTo(1)); harness.Provider.Verify(provider => provider.DeleteAsync( It.IsAny(), It.IsAny()), Times.Never); Assert.That(await harness.Physical.GetEntryAsync("keep", CancellationToken.None).ConfigureAwait(false), Is.Not.Null); harness.VerifyRefreshCommitState(true, 1); harness.VerifyRefreshCommitState(false, 1); } /// /// Verifies that recoverable pre-refresh failures do not replace the error returned by a corrective deletion. /// /// [TestCase("io")] [TestCase("access")] [TestCase("unsupported")] [TestCase("limit")] [TestCase("registration")] [TestCase("missing-directory")] public async Task PreMutationRefreshFailurePreservesProviderDeleteFailureAsync(string failure) { using var harness = new BindingHarness(); await harness.Physical.CreateFileAsync("keep", CancellationToken.None).ConfigureAwait(false); await using IFileDirectoryBinding binding = await harness.BindAsync(2).ConfigureAwait(false); NodeId keptId = harness.FindFile("keep").NodeId; harness.RefreshFailure = failure switch { "io" => new IOException("refresh unavailable"), "access" => new UnauthorizedAccessException("enumeration denied"), "unsupported" => new NotSupportedException("enumeration unsupported"), "limit" => new ServiceResultException(StatusCodes.BadEncodingLimitsExceeded), "registration" => new InvalidOperationException("registration unavailable"), "missing-directory" => new DirectoryNotFoundException("refresh directory unavailable"), _ => throw new ArgumentOutOfRangeException(nameof(failure)) }; (ServiceResult created, _) = await FileReadRegressionTests.CallAsync( harness.Root.CreateFile!, harness.Context, harness.Root.NodeId, ["created", false]) .ConfigureAwait(false); Assert.That(created.StatusCode, Is.EqualTo(StatusCodes.Good)); harness.Provider.Setup(provider => provider.DeleteAsync("keep", It.IsAny())) .ThrowsAsync(new UnauthorizedAccessException("delete denied")); (ServiceResult deleted, _) = await FileReadRegressionTests.CallAsync( harness.Root.DeleteFileSystemObject!, harness.Context, harness.Root.NodeId, [keptId]) .ConfigureAwait(false); Assert.That(deleted.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied)); Assert.That(harness.MutationCount, Is.EqualTo(1)); harness.Provider.Verify(provider => provider.DeleteAsync("keep", It.IsAny()), Times.Once); Assert.That(await harness.Physical.GetEntryAsync("keep", CancellationToken.None).ConfigureAwait(false), Is.Not.Null); harness.VerifyRefreshCommitState(true, 1); harness.VerifyRefreshCommitState(false, 1); } /// /// Verifies that refresh retains the old lookup until registration of replacement nodes completes. /// [Test] public async Task RefreshPublishesLookupOnlyAfterRegistrationCompletesAsync() { using var harness = new BindingHarness(); await harness.Physical.CreateFileAsync("old", CancellationToken.None).ConfigureAwait(false); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await using IFileDirectoryBinding binding = await harness.BindAsync(2, async (node, ct) => { if (node.BrowseName.Name == "new") { entered.TrySetResult(true); await release.Task.WaitAsync(ct).ConfigureAwait(false); } }).ConfigureAwait(false); var host = (IFileSystemHost)binding; await harness.Physical.CreateFileAsync("new", CancellationToken.None).ConfigureAwait(false); await harness.Physical.DeleteAsync("old", CancellationToken.None).ConfigureAwait(false); Task refresh = binding.RefreshAsync().AsTask(); try { await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); Assert.That(host.TryGetProviderPath(host.BuildFileNodeId("old"), out _, out _, out _), Is.True); Assert.That(host.TryGetProviderPath(host.BuildFileNodeId("new"), out _, out _, out _), Is.False); } finally { release.TrySetResult(true); await refresh.ConfigureAwait(false); } Assert.That(host.TryGetProviderPath(host.BuildFileNodeId("old"), out _, out _, out _), Is.False); Assert.That(host.TryGetProviderPath(host.BuildFileNodeId("new"), out string path, out _, out _), Is.True); Assert.That(path, Is.EqualTo("new")); } /// /// Verifies that disposal retires path lookup and handle admission before invoking provider stream cleanup. /// [Test] public async Task DisposalRetiresLookupAndHandlesBeforeCallingProviderCleanupAsync() { using var harness = new BindingHarness(); await harness.Physical.CreateFileAsync("first", CancellationToken.None).ConfigureAwait(false); await harness.Physical.CreateFileAsync("second", CancellationToken.None).ConfigureAwait(false); IFileDirectoryBinding binding = await harness.BindAsync(2).ConfigureAwait(false); var host = (IFileSystemHost)binding; bool lookupWasRetired = false; bool handleWasRejected = false; using var stream = new CleanupStream(() => { lookupWasRetired = !host.TryGetProviderPath(host.BuildFileNodeId("first"), out _, out _, out _); handleWasRejected = host.GetOrCreateHandle(host.BuildFileNodeId("second"), "second") == null; }); harness.Provider.Setup(value => value.OpenReadAsync("first", It.IsAny())) .ReturnsAsync(stream); await FileReadRegressionTests.OpenAsync(harness.FindFile("first"), harness.Context).ConfigureAwait(false); await binding.DisposeAsync().ConfigureAwait(false); Assert.That(lookupWasRetired, Is.True); Assert.That(handleWasRejected, Is.True); Assert.That(stream.CanRead, Is.False); Assert.That(host.TryGetProviderPath(harness.Root.NodeId, out _, out _, out _), Is.False); await binding.DisposeAsync().ConfigureAwait(false); await binding.RefreshAsync().ConfigureAwait(false); } /// /// Verifies that concurrent create requests serialize capacity admission and only one uses the final slot. /// [Test] public async Task ConcurrentCreatesCannotBothConsumeTheLastCapacitySlotAsync() { using var harness = new BindingHarness(); await using IFileDirectoryBinding binding = await harness.BindAsync(1).ConfigureAwait(false); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); harness.Provider.Setup(value => value.CreateFileAsync("first", It.IsAny())) .Returns(async (string path, CancellationToken ct) => { entered.TrySetResult(true); await release.Task.WaitAsync(ct).ConfigureAwait(false); await harness.CreateFileAsync(path, ct).ConfigureAwait(false); }); Task<(ServiceResult Result, List Output)> first = FileReadRegressionTests.CallAsync( harness.Root.CreateFile!, harness.Context, harness.Root.NodeId, ["first", false]).AsTask(); Task<(ServiceResult Result, List Output)>? second = null; try { await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); second = FileReadRegressionTests.CallAsync( harness.Root.CreateFile!, harness.Context, harness.Root.NodeId, ["second", false]).AsTask(); Assert.That(second.IsCompleted, Is.False); } finally { release.TrySetResult(true); await first.ConfigureAwait(false); if (second != null) { await second.ConfigureAwait(false); } } Assert.That((await first.ConfigureAwait(false)).Result.StatusCode, Is.EqualTo(StatusCodes.Good)); Assert.That((await second!.ConfigureAwait(false)).Result.StatusCode, Is.EqualTo(StatusCodes.BadEncodingLimitsExceeded)); Assert.That(harness.MutationCount, Is.EqualTo(1)); Assert.That(await harness.Physical.GetEntryAsync("second", CancellationToken.None).ConfigureAwait(false), Is.Null); } /// /// Verifies that failed node registration is retried before a committed provider entry becomes discoverable. /// [Test] public async Task FailedRegistrationIsRetriedBeforeLookupIsPublishedAsync() { using var harness = new BindingHarness(); int attempts = 0; await using IFileDirectoryBinding binding = await harness.BindAsync(2, (_, _) => { if (++attempts == 1) { throw new IOException("registration unavailable"); } return default; }).ConfigureAwait(false); (ServiceResult result, _) = await FileReadRegressionTests.CallAsync( harness.Root.CreateFile!, harness.Context, harness.Root.NodeId, ["created", false]).ConfigureAwait(false); var host = (IFileSystemHost)binding; Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good)); Assert.That(host.TryGetProviderPath(host.BuildFileNodeId("created"), out _, out _, out _), Is.False); Assert.That(attempts, Is.EqualTo(1)); await binding.RefreshAsync().ConfigureAwait(false); Assert.That(attempts, Is.EqualTo(2)); Assert.That(host.TryGetProviderPath(host.BuildFileNodeId("created"), out _, out _, out _), Is.True); } /// /// Verifies that a cancelled waiter leaves the active refresh protected and queued disposal rejects late /// mutation. /// [Test] public async Task CancelledWaiterAndQueuedDisposalDrainWithoutReleasingAnotherOperationsGateAsync() { using var harness = new BindingHarness(); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); IFileDirectoryBinding binding = await harness.BindAsync(3, async (_, ct) => { entered.TrySetResult(true); await release.Task.WaitAsync(ct).ConfigureAwait(false); }).ConfigureAwait(false); await harness.Physical.CreateFileAsync("new", CancellationToken.None).ConfigureAwait(false); Task refresh = binding.RefreshAsync().AsTask(); Task? disposing = null; Task<(ServiceResult Result, List Output)>? mutation = null; try { await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); using var cancellation = new CancellationTokenSource(); Task waiting = binding.RefreshAsync(cancellation.Token).AsTask(); cancellation.Cancel(); Assert.ThrowsAsync(async () => await waiting.ConfigureAwait(false)); Assert.That(refresh.IsCompleted, Is.False); disposing = binding.DisposeAsync().AsTask(); mutation = FileReadRegressionTests.CallAsync( harness.Root.CreateFile!, harness.Context, harness.Root.NodeId, ["late", false]).AsTask(); Assert.That(disposing.IsCompleted, Is.False); Assert.That(mutation.IsCompleted, Is.False); } finally { release.TrySetResult(true); await refresh.ConfigureAwait(false); if (disposing != null) { await disposing.ConfigureAwait(false); } if (mutation != null) { await mutation.ConfigureAwait(false); } await binding.DisposeAsync().ConfigureAwait(false); } Assert.That((await mutation!.ConfigureAwait(false)).Result.StatusCode, Is.EqualTo(StatusCodes.BadShutdown)); Assert.That(harness.MutationCount, Is.Zero); } /// /// Defines the entries expected after corrective deletion restores an over-capacity directory. /// private static readonly string[] s_recoveredPaths = ["renamed", "external"]; /// /// Supplies an isolated physical directory with observable mutations and injectable refresh failures. /// private sealed class BindingHarness : IDisposable { /// /// Creates a writable provider, session context, root node, and logger for binding lifecycle tests. /// public BindingHarness() { m_path = Path.Combine(Path.GetTempPath(), "FileDirectoryRegression-" + Guid.NewGuid().ToString("N")); Physical = new PhysicalFileSystemProvider(m_path, "regression"); Provider.SetupGet(value => value.MountName).Returns("regression"); Provider.SetupGet(value => value.IsWritable).Returns(true); Provider.Setup(value => value.EnumerateAsync(It.IsAny(), It.IsAny())) .Returns((string path, CancellationToken ct) => EnumerateAsync(path, ct)); Provider.Setup(value => value.GetEntryAsync(It.IsAny(), It.IsAny())) .Returns((string path, CancellationToken ct) => Physical.GetEntryAsync(path, ct)); Provider.Setup(value => value.CreateFileAsync(It.IsAny(), It.IsAny())) .Returns((string path, CancellationToken ct) => CreateFileAsync(path, ct)); Provider.Setup(value => value.CreateDirectoryAsync(It.IsAny(), It.IsAny())) .Returns((string path, CancellationToken ct) => { Interlocked.Increment(ref m_mutationCount); return Physical.CreateDirectoryAsync(path, ct); }); Provider.Setup(value => value.DeleteAsync(It.IsAny(), It.IsAny())) .Returns((string path, CancellationToken ct) => { Interlocked.Increment(ref m_mutationCount); return Physical.DeleteAsync(path, ct); }); Provider.Setup(value => value.MoveAsync( It.IsAny(), It.IsAny(), It.IsAny())) .Returns((string source, string target, CancellationToken ct) => { Interlocked.Increment(ref m_mutationCount); return Physical.MoveAsync(source, target, ct); }); Provider.Setup(value => value.CopyAsync( It.IsAny(), It.IsAny(), It.IsAny())) .Returns((string source, string target, CancellationToken ct) => { Interlocked.Increment(ref m_mutationCount); return Physical.CopyAsync(source, target, ct); }); Logger.Setup(value => value.IsEnabled(It.IsAny())).Returns(true); var factory = new Mock(); factory.Setup(value => value.CreateLogger(It.IsAny())).Returns(Logger.Object); var telemetry = new Mock(); telemetry.SetupGet(value => value.LoggerFactory).Returns(factory.Object); var namespaces = new NamespaceTable(); namespaces.Append("urn:file-directory-regression"); Context = new SessionSystemContext(telemetry.Object) { NamespaceUris = namespaces, ServerUris = new StringTable(), TypeTable = new TypeTable(namespaces), SessionId = new NodeId("session", 1) }; Root = new FileDirectoryState(null) { TypeDefinitionId = ObjectTypeIds.FileDirectoryType, NodeId = new NodeId("root", 1), BrowseName = new QualifiedName("root", 1), DisplayName = new LocalizedText("root") }; } /// /// Gets the physical provider used to inspect or change backing entries independently of the binding. /// public PhysicalFileSystemProvider Physical { get; } /// /// Gets the observable provider injected into the directory binding. /// public Mock Provider { get; } = new(); /// /// Gets the logger used to verify refresh failures and commit-state diagnostics. /// public Mock Logger { get; } = new(); /// /// Gets the session and namespace context for directory method calls. /// public SessionSystemContext Context { get; } /// /// Gets the directory node whose children and methods are populated by the binding. /// public FileDirectoryState Root { get; } /// /// Gets or sets the exception raised during enumeration after the first observed mutation. /// public Exception? RefreshFailure { get; set; } /// /// Gets or sets a callback invoked before enumeration to trigger controlled cancellation. /// public Action? BeforeEnumeration { get; set; } /// /// Gets the number of provider mutations admitted through the observable provider. /// public int MutationCount => m_mutationCount; /// /// Binds the root with an entry limit and optional asynchronous node-registration callback. /// public ValueTask BindAsync( int maximum, Func? registerNode = null) { return new FileDirectoryBinder().BindAsync( Root, Provider.Object, Context, new FileDirectoryBindingOptions { MaxEntries = maximum }, registerNode); } /// /// Finds a materialized child directory and fails the test if it is absent. /// /// public FileDirectoryState FindDirectory(string name) { return (FileDirectoryState)(Root.FindChild(Context, new QualifiedName(name, 1)) ?? throw new AssertionException("The expected directory was not materialized.")); } /// /// Finds a materialized child file and fails the test if it is absent. /// /// public FileState FindFile(string name) { return (FileState)(Root.FindChild(Context, new QualifiedName(name, 1)) ?? throw new AssertionException("The expected file was not materialized.")); } /// /// Counts an admitted file creation before forwarding it to the physical provider. /// public ValueTask CreateFileAsync(string path, CancellationToken ct) { Interlocked.Increment(ref m_mutationCount); return Physical.CreateFileAsync(path, ct); } /// /// Verifies the exact number of logged refresh failures caused by exceeding the entry limit. /// public void VerifyRefreshLimitFailures(int count) { Logger.Verify(logger => logger.Log( LogLevel.Error, It.Is(id => id.Name == "FileDirectoryRefreshFailed"), It.IsAny(), It.Is(exception => exception is ServiceResultException && ((ServiceResultException)exception).StatusCode == StatusCodes.BadEncodingLimitsExceeded), It.IsAny>()), Times.Exactly(count)); } /// /// Verifies how many refresh failures were logged before or after the current provider mutation committed. /// public void VerifyRefreshCommitState(bool committed, int count) { Logger.Verify(logger => logger.Log( LogLevel.Error, It.Is(id => id.Name == "FileDirectoryRefreshFailed"), It.Is((state, _) => state.ToString()!.Contains("current provider mutation committed: " + committed)), It.IsAny(), It.IsAny>()), Times.Exactly(count)); } /// /// Removes the temporary directory and all backing entries created by the test. /// public void Dispose() { Directory.Delete(m_path, recursive: true); } /// /// Enumerates the provider entries after running the configured refresh barrier or failure. /// private async IAsyncEnumerable EnumerateAsync( string path, [EnumeratorCancellation] CancellationToken ct) { await Task.CompletedTask.ConfigureAwait(false); BeforeEnumeration?.Invoke(); if (MutationCount != 0 && RefreshFailure != null) { throw RefreshFailure; } await foreach (FileSystemEntry entry in Physical.EnumerateAsync(path, ct).ConfigureAwait(false)) { yield return entry; } } /// /// Locates the temporary directory owned by this binding harness. /// private readonly string m_path; /// /// Counts provider mutations independently of address-space refresh success. /// private int m_mutationCount; } /// /// Invokes a callback during first disposal to inspect binding state from provider cleanup. /// private sealed class CleanupStream(Action onDispose) : MemoryStream(new byte[1]) { /// /// Runs the inspection callback once before releasing the readable stream. /// protected override void Dispose(bool disposing) { if (disposing && CanRead) { onDispose(); } base.Dispose(disposing); } } } }