mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 14:43:08 +08:00
9b034f8291
# Description Nullable reference types are now enabled once, in `common.props`, and every project in the repository builds warning-clean with them. **Configuration** - `<Nullable>enable</Nullable>` lives in `common.props`. All per-project `<Nullable>` settings are removed: 178 project files (enable, annotations, and the `disable` in the three Aot test projects). - 419 per-file `#nullable enable`/`restore` directives and the 4 `#nullable disable` directives are removed. - Kept on purpose: - The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`, `PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the project setting in generated code, so the directive is required there. - `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against the 1.5.378 packages and does not import the repository props. - `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now describe the central setting. **Migration** (the 43 unset and 13 annotations-only projects, roughly 10k diagnostics) - **Production code: annotated at the declarations.** Covers `tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the LDS sample. - Members, parameters and returns are nullable where null actually flows. - Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`, `IsMethodTypeDesign`). - `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take `params object?[]`. `Template.AddReplacement` takes `string?` (rendering already skips null replacements). - `IRegisteredServerStore.RegisterAsync` returns `Task<RegistrationEntry?>`: it returns null when a registration is removed. - Dereferences backed by an invariant (for example, the validated model) use `!`, so runtime behaviour is unchanged. - **Behavioural changes in production code** (small, deliberate): - `NodeSetToModelDesign` throws `InvalidOperationException` when the file is not a NodeSet. Before, it threw a NullReferenceException on the next line. - `ModelDesignValidator` starts its lookup tables empty instead of null. A redundant `if (m_nodes == null) return;` guard is gone. - **Test code: minimal annotations.** - `?` on values that can be null, `!` where the test asserts non-null. - `= null!` for fixture members assigned in SetUp. - `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the `IEncodeable` constraint. - A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`) folded into the adjacent `IsNull`/`Count` checks. **Notes for reviewers** - The diff is large (~1,500 files) but almost entirely mechanical: `?`, `!` and directive removal. The non-annotation changes are listed above. - Known follow-up, not done here: `IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay non-nullable even though the validator returns null before it loads a dictionary. Making them nullable would ripple through every generator call site. ## Related Issues - Fixes #4439 ## 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. - [ ] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. The "no new warnings" box covers net10.0 and net48: full non-incremental builds of `UA.slnx`, `tools/SourceGeneration.slnx`, `tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0 warnings and 0 errors under the repository's warnings-as-errors settings. net9.0 and net8.0 also had no nullable diagnostics, but they were not rebuilt after the final fixes. Tests have not been run locally yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
793 lines
42 KiB
C#
793 lines
42 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/
|
|
* ======================================================================*/
|
|
|
|
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
|
|
{
|
|
/// <summary>
|
|
/// Verifies directory capacity admission, refresh publication, and cleanup across concurrent binding operations.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("FileSystem")]
|
|
public sealed class FileDirectoryLifecycleRegressionTests
|
|
{
|
|
/// <summary>
|
|
/// Verifies that create, copy, and move operations check destination capacity before mutating the provider.
|
|
/// </summary>
|
|
[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<Variant> 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"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that renaming within a full directory succeeds without reserving an additional entry.
|
|
/// </summary>
|
|
[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"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that post-commit refresh failures are logged without reporting a successful mutation as failed.
|
|
/// </summary>
|
|
[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<Variant> 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<EventId>(id => id.Name == "FileDirectoryRefreshFailed"),
|
|
It.IsAny<It.IsAnyType>(),
|
|
harness.RefreshFailure,
|
|
It.IsAny<Func<It.IsAnyType, Exception?, string>>()), 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"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a corrective deletion can restore capacity and publish a fresh lookup after repeated failures.
|
|
/// </summary>
|
|
[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<Variant> 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<ServiceResultException>(
|
|
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<CancellationToken>()),
|
|
Times.Once);
|
|
var paths = new List<string>();
|
|
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<BaseInstanceState>();
|
|
harness.Root.GetChildren(harness.Context, children);
|
|
Assert.That(children.OfType<FileState>().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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that repeated refresh failures do not permit a create operation to exceed directory capacity.
|
|
/// </summary>
|
|
[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<string>(), It.IsAny<CancellationToken>()), 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that cancellation thrown by the pre-mutation refresh prevents provider deletion.
|
|
/// </summary>
|
|
[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<OperationCanceledException>(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<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
Assert.That(await harness.Physical.GetEntryAsync("keep", CancellationToken.None).ConfigureAwait(false),
|
|
Is.Not.Null);
|
|
harness.VerifyRefreshCommitState(true, 1);
|
|
harness.VerifyRefreshCommitState(false, 0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that request cancellation during a failing pre-refresh is propagated before provider mutation.
|
|
/// </summary>
|
|
[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<OperationCanceledException>(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<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
Assert.That(await harness.Physical.GetEntryAsync("keep", CancellationToken.None).ConfigureAwait(false),
|
|
Is.Not.Null);
|
|
harness.VerifyRefreshCommitState(true, 1);
|
|
harness.VerifyRefreshCommitState(false, 1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that recoverable pre-refresh failures do not replace the error returned by a corrective deletion.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentOutOfRangeException"></exception>
|
|
[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<CancellationToken>()))
|
|
.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<CancellationToken>()),
|
|
Times.Once);
|
|
Assert.That(await harness.Physical.GetEntryAsync("keep", CancellationToken.None).ConfigureAwait(false),
|
|
Is.Not.Null);
|
|
harness.VerifyRefreshCommitState(true, 1);
|
|
harness.VerifyRefreshCommitState(false, 1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that refresh retains the old lookup until registration of replacement nodes completes.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task RefreshPublishesLookupOnlyAfterRegistrationCompletesAsync()
|
|
{
|
|
using var harness = new BindingHarness();
|
|
await harness.Physical.CreateFileAsync("old", CancellationToken.None).ConfigureAwait(false);
|
|
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var release = new TaskCompletionSource<bool>(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"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that disposal retires path lookup and handle admission before invoking provider stream cleanup.
|
|
/// </summary>
|
|
[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<CancellationToken>()))
|
|
.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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that concurrent create requests serialize capacity admission and only one uses the final slot.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ConcurrentCreatesCannotBothConsumeTheLastCapacitySlotAsync()
|
|
{
|
|
using var harness = new BindingHarness();
|
|
await using IFileDirectoryBinding binding = await harness.BindAsync(1).ConfigureAwait(false);
|
|
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
harness.Provider.Setup(value => value.CreateFileAsync("first", It.IsAny<CancellationToken>()))
|
|
.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<Variant> Output)> first = FileReadRegressionTests.CallAsync(
|
|
harness.Root.CreateFile!, harness.Context, harness.Root.NodeId, ["first", false]).AsTask();
|
|
Task<(ServiceResult Result, List<Variant> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that failed node registration is retried before a committed provider entry becomes discoverable.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a cancelled waiter leaves the active refresh protected and queued disposal rejects late
|
|
/// mutation.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task CancelledWaiterAndQueuedDisposalDrainWithoutReleasingAnotherOperationsGateAsync()
|
|
{
|
|
using var harness = new BindingHarness();
|
|
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var release = new TaskCompletionSource<bool>(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<Variant> 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<OperationCanceledException>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Defines the entries expected after corrective deletion restores an over-capacity directory.
|
|
/// </summary>
|
|
private static readonly string[] s_recoveredPaths = ["renamed", "external"];
|
|
|
|
/// <summary>
|
|
/// Supplies an isolated physical directory with observable mutations and injectable refresh failures.
|
|
/// </summary>
|
|
private sealed class BindingHarness : IDisposable
|
|
{
|
|
/// <summary>
|
|
/// Creates a writable provider, session context, root node, and logger for binding lifecycle tests.
|
|
/// </summary>
|
|
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<string>(), It.IsAny<CancellationToken>()))
|
|
.Returns((string path, CancellationToken ct) => EnumerateAsync(path, ct));
|
|
Provider.Setup(value => value.GetEntryAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.Returns((string path, CancellationToken ct) => Physical.GetEntryAsync(path, ct));
|
|
Provider.Setup(value => value.CreateFileAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.Returns((string path, CancellationToken ct) => CreateFileAsync(path, ct));
|
|
Provider.Setup(value => value.CreateDirectoryAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.Returns((string path, CancellationToken ct) =>
|
|
{
|
|
Interlocked.Increment(ref m_mutationCount);
|
|
return Physical.CreateDirectoryAsync(path, ct);
|
|
});
|
|
Provider.Setup(value => value.DeleteAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.Returns((string path, CancellationToken ct) =>
|
|
{
|
|
Interlocked.Increment(ref m_mutationCount);
|
|
return Physical.DeleteAsync(path, ct);
|
|
});
|
|
Provider.Setup(value => value.MoveAsync(
|
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.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<LogLevel>())).Returns(true);
|
|
var factory = new Mock<ILoggerFactory>();
|
|
factory.Setup(value => value.CreateLogger(It.IsAny<string>())).Returns(Logger.Object);
|
|
var telemetry = new Mock<ITelemetryContext>();
|
|
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")
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the physical provider used to inspect or change backing entries independently of the binding.
|
|
/// </summary>
|
|
public PhysicalFileSystemProvider Physical { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the observable provider injected into the directory binding.
|
|
/// </summary>
|
|
public Mock<IFileSystemProvider> Provider { get; } = new();
|
|
|
|
/// <summary>
|
|
/// Gets the logger used to verify refresh failures and commit-state diagnostics.
|
|
/// </summary>
|
|
public Mock<ILogger> Logger { get; } = new();
|
|
|
|
/// <summary>
|
|
/// Gets the session and namespace context for directory method calls.
|
|
/// </summary>
|
|
public SessionSystemContext Context { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the directory node whose children and methods are populated by the binding.
|
|
/// </summary>
|
|
public FileDirectoryState Root { get; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets the exception raised during enumeration after the first observed mutation.
|
|
/// </summary>
|
|
public Exception? RefreshFailure { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets a callback invoked before enumeration to trigger controlled cancellation.
|
|
/// </summary>
|
|
public Action? BeforeEnumeration { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets the number of provider mutations admitted through the observable provider.
|
|
/// </summary>
|
|
public int MutationCount => m_mutationCount;
|
|
|
|
/// <summary>
|
|
/// Binds the root with an entry limit and optional asynchronous node-registration callback.
|
|
/// </summary>
|
|
public ValueTask<IFileDirectoryBinding> BindAsync(
|
|
int maximum,
|
|
Func<NodeState, CancellationToken, ValueTask>? registerNode = null)
|
|
{
|
|
return new FileDirectoryBinder().BindAsync(
|
|
Root, Provider.Object, Context, new FileDirectoryBindingOptions { MaxEntries = maximum },
|
|
registerNode);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds a materialized child directory and fails the test if it is absent.
|
|
/// </summary>
|
|
/// <exception cref="AssertionException"></exception>
|
|
public FileDirectoryState FindDirectory(string name)
|
|
{
|
|
return (FileDirectoryState)(Root.FindChild(Context, new QualifiedName(name, 1)) ??
|
|
throw new AssertionException("The expected directory was not materialized."));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds a materialized child file and fails the test if it is absent.
|
|
/// </summary>
|
|
/// <exception cref="AssertionException"></exception>
|
|
public FileState FindFile(string name)
|
|
{
|
|
return (FileState)(Root.FindChild(Context, new QualifiedName(name, 1)) ??
|
|
throw new AssertionException("The expected file was not materialized."));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Counts an admitted file creation before forwarding it to the physical provider.
|
|
/// </summary>
|
|
public ValueTask CreateFileAsync(string path, CancellationToken ct)
|
|
{
|
|
Interlocked.Increment(ref m_mutationCount);
|
|
return Physical.CreateFileAsync(path, ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies the exact number of logged refresh failures caused by exceeding the entry limit.
|
|
/// </summary>
|
|
public void VerifyRefreshLimitFailures(int count)
|
|
{
|
|
Logger.Verify(logger => logger.Log(
|
|
LogLevel.Error,
|
|
It.Is<EventId>(id => id.Name == "FileDirectoryRefreshFailed"),
|
|
It.IsAny<It.IsAnyType>(),
|
|
It.Is<Exception>(exception => exception is ServiceResultException &&
|
|
((ServiceResultException)exception).StatusCode == StatusCodes.BadEncodingLimitsExceeded),
|
|
It.IsAny<Func<It.IsAnyType, Exception?, string>>()), Times.Exactly(count));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies how many refresh failures were logged before or after the current provider mutation committed.
|
|
/// </summary>
|
|
public void VerifyRefreshCommitState(bool committed, int count)
|
|
{
|
|
Logger.Verify(logger => logger.Log(
|
|
LogLevel.Error,
|
|
It.Is<EventId>(id => id.Name == "FileDirectoryRefreshFailed"),
|
|
It.Is<It.IsAnyType>((state, _) =>
|
|
state.ToString()!.Contains("current provider mutation committed: " + committed)),
|
|
It.IsAny<Exception>(),
|
|
It.IsAny<Func<It.IsAnyType, Exception?, string>>()), Times.Exactly(count));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes the temporary directory and all backing entries created by the test.
|
|
/// </summary>
|
|
public void Dispose()
|
|
{
|
|
Directory.Delete(m_path, recursive: true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enumerates the provider entries after running the configured refresh barrier or failure.
|
|
/// </summary>
|
|
private async IAsyncEnumerable<FileSystemEntry> 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Locates the temporary directory owned by this binding harness.
|
|
/// </summary>
|
|
private readonly string m_path;
|
|
|
|
/// <summary>
|
|
/// Counts provider mutations independently of address-space refresh success.
|
|
/// </summary>
|
|
private int m_mutationCount;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Invokes a callback during first disposal to inspect binding state from provider cleanup.
|
|
/// </summary>
|
|
private sealed class CleanupStream(Action onDispose) : MemoryStream(new byte[1])
|
|
{
|
|
/// <summary>
|
|
/// Runs the inspection callback once before releasing the readable stream.
|
|
/// </summary>
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (disposing && CanRead)
|
|
{
|
|
onDispose();
|
|
}
|
|
base.Dispose(disposing);
|
|
}
|
|
}
|
|
}
|
|
}
|