mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 06:31:35 +08:00
e78d36ce4b
# Description Layer 2 of the upstream non-UaLens extraction, with core/types prerequisite #4801 (`marcschier-extract-core-and-types`). The initial extraction commit `86ff10d3994f132f7343a929abdae209bc2e69b7` started from `8881e096fb5e4eaa36f3c13f8caa57733373bd19` and copied exactly 13 client/server paths from #4462 at frozen head `390c4a7a51b71da40caec2d22ef74518bbe670ce`, using its original base `5b03e9afa492cdb36049ee52ae844f630f89d8db`. Those initial blobs were source-identical; the core/types layer was not duplicated or rewritten. - Add bounded temporary-file copying with byte/request limits, session ownership checks, and independent bounded cleanup. - Refresh revised monitored-item parameters after snapshot transfer and notify the target handler after successful handle synchronization. - Apply WSS opening deadlines while preserving caller cancellation, and cover the wire envelope and request timeout behavior. - Make hosted server presets honor role configuration without replacing an explicitly registered role manager; include the corresponding tests and documentation. **Review follow-up:** commit `a5c8fe0884ad3346106529deba71d93c5df20a9a` intentionally corrects three defects in the extracted bounded-copy implementation. It captures one `SessionRequestContext` for managed transfers, calls the standard `FileType.Close` declaration without metadata discovery, and retains valid generation handles before rejecting non-Good status or malformed metadata. Read/Close remain strictly validated, and transfer/cleanup failures remain independently visible. The follow-up also adds 21 direct-client regression cases and updates `docs/FileSystemClient.md`, bringing this PR to 15 changed paths. The current bounded-copy implementation is therefore intentionally no longer identical to the frozen source. No UaLens projects/UI, AMB or Machinery implementations, samples, CI/native renderer changes, release changes, or generic infrastructure files are included. The follow-up does not merge/rebase a lower or upper stack layer. **Current review-fix validation:** normal analyzers and warnings-as-errors remained enabled, using the existing SDK **10.0.401** without changing `global.json`, installing an SDK, or adding suppressions. The final file-system and session-context selection passed **241 tests on net10.0 and 241 tests on net48**, with **0 failed and 0 skipped** on each. Both selections include all 21 new regressions. Before the production fix, the first 20-case regression suite had 13 failures and 7 passes, reproducing the lost cleanup, cancellation, session-recreation and Close-discovery defects. An additional deterministic recovery-gate race case was then added and passed on both frameworks. Informational-severity formatting/style/analyzer checks passed for the changed C# files. | Review requirement | Direct regression evidence | | --- | --- | | Pin the managed generation through copying and cleanup | `CopyRejectsRecreatedManagedGenerationAsync`, `CopyRejectsRecoveryBetweenGenerationAndReadDispatchAsync` | | Preserve a generation response that returns after cancellation | `ManagedCopyClosesHandleReturnedAfterCancellationAsync` with immediate and delayed responses through a real managed transport lease | | Do not spend the Close deadline discovering metadata | `CopyUsesDeclarationCloseWithoutDiscoveryAsync` for ordinary and managed sessions | | Retain valid handles before rejecting generation status/metadata | `CopyClosesHandleBeforeRejectingGenerationStatusAsync`, `CopyClosesHandleFromMalformedGenerationResponseAsync` | | Preserve error semantics and strict Read/Close validation | `CopyRejectsGenerationWithoutInventingHandleAsync`, `CopyPreservesGenerationAndCleanupFailuresAsync`, `CopyKeepsReadAndCloseStatusValidationAsync` | <details> <summary>Exact review-fix validation commands and results</summary> Commands ran from this layer's repository root. The following variables expand the actual executable, filters and results paths. Console logs and TRX reports were retained in the session artifacts. ```powershell $dotnet = 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' $env:DOTNET_ROOT = 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk' $env:DOTNET_ROOT_X64 = $env:DOTNET_ROOT $env:DOTNET_CLI_TELEMETRY_OPTOUT = '1' $client = 'tests\Opc.Ua.Client.Tests\Opc.Ua.Client.Tests.csproj' $filter = 'FullyQualifiedName~Opc.Ua.Client.Tests.FileSystem|FullyQualifiedName~Opc.Ua.Client.Tests.SessionContexts' $r10 = 'C:\Users\mschier\.copilot\session-state\9b51b89f-b5ed-4ed7-a097-cca67efe7b0e\files\feedback-net10' $r48 = 'C:\Users\mschier\.copilot\session-state\9b51b89f-b5ed-4ed7-a097-cca67efe7b0e\files\feedback-net48' # Before modifying production code, after the new tests compiled: & $dotnet test $client --no-restore -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~TemporaryFileTransferClientBoundedReadTests' --logger 'trx;LogFileName=bounded-copy-feedback-red.trx' --results-directory $r10 --verbosity quiet # Expected regression failure: Passed: 7, Failed: 13, Skipped: 0. # After the production fix, before adding the explicit queued-recovery case: & $dotnet test $client --no-restore -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~TemporaryFileTransferClientBoundedReadTests' --logger 'trx;LogFileName=bounded-copy-feedback-green.trx' --results-directory $r10 --verbosity quiet # Passed: 20, Failed: 0, Skipped: 0. # Final complete affected selections, including all 21 new cases: & $dotnet test $client --no-restore -c Release -f net48 -p:CustomTestTarget=net48 --filter $filter --logger 'trx;LogFileName=file-system-context-feedback-final.trx' --results-directory $r48 --verbosity quiet # Passed: 241, Failed: 0, Skipped: 0. & $dotnet test $client -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter $filter --logger 'trx;LogFileName=file-system-context-feedback-final.trx' --results-directory $r10 --verbosity quiet # Passed: 241, Failed: 0, Skipped: 0. $sourceFile = 'D:\git\copilot-worktrees\UA-wot-d3-final\marcschier-improved-invention\src\Opc.Ua.Client\FileSystem\TemporaryFileTransferClient.BoundedRead.cs' $testFile = 'D:\git\copilot-worktrees\UA-wot-d3-final\marcschier-improved-invention\tests\Opc.Ua.Client.Tests\FileSystem\TemporaryFileTransferClientBoundedReadTests.cs' $env:CustomTestTarget = 'net48' & $dotnet format 'src\Opc.Ua.Client\Opc.Ua.Client.csproj' --include $sourceFile --no-restore --verify-no-changes --severity info --verbosity quiet # Passed, exit 0. $env:CustomTestTarget = 'net10.0' & $dotnet format $client --include $testFile --no-restore --verify-no-changes --severity info --verbosity quiet # Passed, exit 0. git diff --check # Passed. CRLF, final newlines and the 120-column C# limit were also checked. ``` Initial no-restore test attempts after switching frameworks exited without executing tests and were not counted as passes. Diagnostic `dotnet build ... --no-restore` runs reported `NETSDK1005` for the missing framework assets; normal restore was then allowed. Test-authoring compile errors were corrected before recording the red run, without suppressions. Intermediate broader selections passed 240/240 on each framework before the queued-recovery case increased the final count to 241. </details> **Initial-extraction validation (commit `86ff10d3994f132f7343a929abdae209bc2e69b7`):** these results are historical, not a claim that the broader initial selections were rerun after the review fix. The server test assembly was built as a dependency of the client project before its `--no-build` runs. | Initial targeted scope | net10.0 | net48 | | --- | --- | --- | | Client subscriptions and file system | Passed: 546, Failed: 0, Skipped: 0 | Passed: 542, Failed: 0, Skipped: 0 | | Hosted roles, identity, composition, fluent APIs and builder coverage | Passed: 164, Failed: 0, Skipped: 0 | Passed: 164, Failed: 0, Skipped: 0 | | WebAPI WSS transport | Passed: 37, Failed: 0, Skipped: 0 | Not supported: the repository excludes this .NET 8+ test project on net48 | | V2 transfer and snapshot integration | Passed: 6, Failed: 0, Skipped: 0 | Passed: 6, Failed: 0, Skipped: 0 | The first broader net48 hosting run had **9 startup timeouts and certificate leaks** (155 passed). A single startup case reproduced the failure. With only a shorter `NUnit.WorkDirectory`, the isolated case and then all 164 hosting cases passed, with certificate leak tracking enabled and no teardown leaks. This is consistent with the fixture's documented .NET Framework PKI path-length constraint; no source or diagnostic suppression was changed. Both failed runs and successful reruns are recorded below. **Deferred coverage:** the broader domain-level `MachineryResultTransferTests*.cs` fixtures remain in Machinery layer #4804. They were not imported or run here. This review follow-up adds direct-client coverage for the three defects instead. The Machinery owner has been informed that the old expectations for failed Close discovery and zero cleanup after a returned non-Good generation response must be reconciled when the lower fixes are propagated. **Not run:** complete `UA.slnx` suites on net48/net10.0, a coverage measurement, CI/CodeQL completion, native/fuzz qualifications, or package publishing. This remains a draft pending full-stack integration and CI evidence. <details> <summary>Initial-extraction validation command arguments and outcomes</summary> All commands ran from this layer's repository root. Variables below expand the executable, project paths, filters and results directories used by the actual commands; test output was also retained in session-local log files. ```powershell $dotnet = 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' $env:DOTNET_ROOT = 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk' $env:DOTNET_ROOT_X64 = $env:DOTNET_ROOT $env:DOTNET_CLI_TELEMETRY_OPTOUT = '1' $r10 = 'C:\Users\mschier\.copilot\session-state\9b51b89f-b5ed-4ed7-a097-cca67efe7b0e\files\validation-net10' $r48 = 'C:\Users\mschier\.copilot\session-state\9b51b89f-b5ed-4ed7-a097-cca67efe7b0e\files\validation-net48' $client = 'tests\Opc.Ua.Client.Tests\Opc.Ua.Client.Tests.csproj' $server = 'tests\Opc.Ua.Server.Tests\Opc.Ua.Server.Tests.csproj' $webapi = 'tests\Opc.Ua.Bindings.Https.WebApi.Tests\Opc.Ua.Bindings.Https.WebApi.Tests.csproj' $subscriptions = 'tests\Opc.Ua.Subscriptions.Tests\Opc.Ua.Subscriptions.Tests.csproj' $clientFilter = 'FullyQualifiedName~Opc.Ua.Client.Subscriptions|FullyQualifiedName~Opc.Ua.Client.Tests.FileSystem' $serverFilter = 'FullyQualifiedName~HostedRolePresetTests|FullyQualifiedName~IdentityHostingTests|FullyQualifiedName~ServerFluentApiHostingTests|FullyQualifiedName~ServerCompositionHostingTests|FullyQualifiedName~OpcUaServerBuilderExtensionsCoverageTests' $subscriptionFilter = 'FullyQualifiedName~Opc.Ua.Subscriptions.Tests.TransferSubscriptionTests|FullyQualifiedName~Opc.Ua.Subscriptions.Tests.SubscriptionSnapshotTests' & $dotnet --version # Passed: 10.0.401. & $dotnet test $client --no-restore -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter $clientFilter --logger 'trx;LogFileName=client-net10.trx' --results-directory $r10 --verbosity quiet # Exit 0 but no tests executed: missing restore metadata. NOT counted as validation. & $dotnet build $client --no-restore -c Release -f net10.0 -p:CustomTestTarget=net10.0 --verbosity quiet # Failed: NETSDK1004, missing obj\project.assets.json. Normal restore was then allowed in test commands. & $dotnet test $client -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter $clientFilter --logger 'trx;LogFileName=client-net10.trx' --results-directory $r10 --verbosity quiet # Passed: 546, Failed: 0, Skipped: 0. & $dotnet test $server --no-build --no-restore -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter $serverFilter --logger 'trx;LogFileName=server-net10.trx' --results-directory $r10 --verbosity quiet # Passed: 164, Failed: 0, Skipped: 0. & $dotnet test $webapi -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~WebApiWssTransportChannelTests' --logger 'trx;LogFileName=webapi-net10.trx' --results-directory $r10 --verbosity quiet # Passed: 37, Failed: 0, Skipped: 0. & $dotnet test $client -c Release -f net48 -p:CustomTestTarget=net48 --filter $clientFilter --logger 'trx;LogFileName=client-net48.trx' --results-directory $r48 --verbosity quiet # Passed: 542, Failed: 0, Skipped: 0. & $dotnet test $server --no-build --no-restore -c Release -f net48 -p:CustomTestTarget=net48 --filter $serverFilter --logger 'trx;LogFileName=server-net48.trx' --results-directory $r48 --verbosity quiet # Failed: Passed: 155, Failed: 9, Skipped: 0; startup timeouts and 9 undisposed certificates at teardown. & $dotnet test $subscriptions -c Release -f net48 -p:CustomTestTarget=net48 --filter $subscriptionFilter --logger 'trx;LogFileName=subscriptions-net48.trx' --results-directory $r48 --verbosity quiet # Passed: 6, Failed: 0, Skipped: 0. & $dotnet test $subscriptions -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter $subscriptionFilter --logger 'trx;LogFileName=subscriptions-net10.trx' --results-directory $r10 --verbosity quiet # Passed: 6, Failed: 0, Skipped: 0. & $dotnet msbuild $webapi -p:CustomTestTarget=net48 -p:TargetFramework=net48 -getProperty:TargetFramework,RestrictForLegacyTfm,_RestrictedToLegacyTfm,IsTestProject,EnableDefaultCompileItems # Passed: net48 / true / true / false / false. Confirms the net48 project is an empty non-test shell. # Each following diagnostic invocation had certificate allocation tracking enabled. $env:OPCUA_CERTIFICATE_LEAK_TRACKING = '1' & $dotnet test $server --no-build --no-restore -c Release -f net48 -p:CustomTestTarget=net48 --filter 'FullyQualifiedName~HostedRolePresetTests' --logger 'trx;LogFileName=hosted-role-net48.trx' --results-directory $r48 --verbosity quiet # Passed: 10, Failed: 0, Skipped: 0. & $dotnet test $server --no-build --no-restore -c Release -f net48 -p:CustomTestTarget=net48 --filter 'FullyQualifiedName~ServerFluentApiHostingTests.AddServerWithCustomServerStartsCustomServerAsync' --logger 'trx;LogFileName=hosting-startup-net48.trx' --results-directory $r48 --verbosity quiet # Failed: Passed: 0, Failed: 1, Skipped: 0; 30-second startup timeout and one certificate leak. # A fresh, dedicated short working directory was created for the following two runs. & $dotnet test $server --no-build --no-restore -c Release -f net48 -p:CustomTestTarget=net48 --filter 'FullyQualifiedName~ServerFluentApiHostingTests.AddServerWithCustomServerStartsCustomServerAsync' --logger 'trx;LogFileName=hosting-startup-net48-shortpath.trx' --results-directory $r48 --verbosity quiet -- 'NUnit.WorkDirectory=D:\Temp\opc-l2-6caf4556' # Passed: 1, Failed: 0, Skipped: 0; no certificate leak. & $dotnet test $server --no-build --no-restore -c Release -f net48 -p:CustomTestTarget=net48 --filter $serverFilter --logger 'trx;LogFileName=server-net48-shortpath.trx' --results-directory $r48 --verbosity quiet -- 'NUnit.WorkDirectory=D:\Temp\opc-l2-6caf4556' # Passed: 164, Failed: 0, Skipped: 0; no certificate leak or assembly-level teardown error. # The dedicated short working directory and its generated PKI contents were removed afterward. ``` TRX inspection confirmed all initially added client (3), WSS (4), and hosted-role (10) regression cases executed on supported frameworks. The initial 13-path set and source blobs were verified before the extraction commit. A separate mistyped SDK availability probe (`uals-528-deep-repro` instead of `ualens-528-deep-repro`) failed before launching any process; all actual builds/tests used the correct verified SDK above. </details> ## Related Issues Related to #4461; extracted from source PR #4462. Neither is closed by this PR. Prerequisite: #4801, the core/types layer. _If this is a relatively large or complex change, a design must have been discussed in the related tracking issue and signed off (which becomes the Architectural Decision Record (ADR))._ ## Checklist _Put an `x` in the boxes that apply. You can complete these step by step after opening the PR._ - [ ] 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. - [x] I have addressed **all** PR feedback received.
569 lines
27 KiB
C#
569 lines
27 KiB
C#
/* ========================================================================
|
|
* 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.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Moq;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Client.FileSystem;
|
|
using Opc.Ua.Client.Tests.SessionContexts;
|
|
using Opc.Ua.Client.Tests.Stack.Client.Fakes;
|
|
|
|
namespace Opc.Ua.Client.Tests.FileSystem
|
|
{
|
|
/// <summary>
|
|
/// Verifies bounded generated-file copying through real session dispatch and managed transport leases.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Parallelizable(ParallelScope.All)]
|
|
public sealed class TemporaryFileTransferClientBoundedReadTests
|
|
{
|
|
/// <summary>
|
|
/// A recreated session cannot receive an old handle even when it reuses the public session identifier.
|
|
/// </summary>
|
|
[TestCase(false, false)]
|
|
[TestCase(false, true)]
|
|
[TestCase(true, false)]
|
|
[TestCase(true, true)]
|
|
public async Task CopyRejectsRecreatedManagedGenerationAsync(bool reuseToken, bool duringReadiness)
|
|
{
|
|
await using var fixture = new SessionRequestContextTestContext();
|
|
var script = new TransferScript();
|
|
fixture.Handler = script.DispatchAsync;
|
|
NodeId originalSessionId = fixture.InnerSession.SessionId;
|
|
NodeId originalToken = fixture.InnerSession.SaveSessionConfiguration().AuthenticationToken;
|
|
ValueTask RecreateAsync(CancellationToken ct)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
fixture.InnerSession.SessionCreated(originalSessionId,
|
|
reuseToken ? originalToken : NodeId.Parse("s=replacement-token"));
|
|
return default;
|
|
}
|
|
if (duringReadiness)
|
|
{
|
|
script.GenerationResponse.Results[0].OutputArguments =
|
|
[Variant.From(s_file), Variant.From(kHandle), Variant.From(s_stateMachine)];
|
|
script.OnStateRead = RecreateAsync;
|
|
}
|
|
else
|
|
{
|
|
script.OnGenerate = RecreateAsync;
|
|
}
|
|
script.Chunks.Enqueue(ByteString.Empty);
|
|
var client = new TemporaryFileTransferClient(fixture.ManagedSession, s_transfer);
|
|
using var destination = new MemoryStream();
|
|
|
|
AggregateException failure = Assert.ThrowsAsync<AggregateException>(
|
|
async () => await client.CopyToAsync(default, destination, 3).ConfigureAwait(false))!;
|
|
|
|
Assert.That(failure.InnerExceptions, Has.Count.EqualTo(2));
|
|
Assert.That(failure.InnerExceptions, Has.All.TypeOf<ServiceResultException>()
|
|
.With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.BadSessionIdInvalid));
|
|
Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod }));
|
|
Assert.That(fixture.Requests, Has.Count.EqualTo(duringReadiness ? 4 : 2));
|
|
Assert.That(fixture.Requests.Select(request => request.RequestHeader.AuthenticationToken),
|
|
Is.All.EqualTo(originalToken));
|
|
Assert.That(destination.Length, Is.Zero);
|
|
Assert.That(fixture.InnerSession.Connected, Is.True);
|
|
fixture.Channel.Verify(channel => channel.CloseAsync(It.IsAny<CancellationToken>()), Times.Never);
|
|
fixture.Channel.Verify(channel => channel.Dispose(), Times.Never);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recovery queued behind generation is rechecked at the next dispatch, not just before entering the gate.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task CopyRejectsRecoveryBetweenGenerationAndReadDispatchAsync()
|
|
{
|
|
await using var fixture = new SessionRequestContextTestContext();
|
|
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
async Task RecreateBehindDispatchAsync()
|
|
{
|
|
using (await fixture.ServiceGate.WriterLockAsync().ConfigureAwait(false))
|
|
{
|
|
entered.TrySetResult(true);
|
|
await release.Task.ConfigureAwait(false);
|
|
fixture.Recreate();
|
|
}
|
|
}
|
|
Task? recovery = null;
|
|
var script = new TransferScript
|
|
{
|
|
OnGenerate = _ =>
|
|
{
|
|
recovery = RecreateBehindDispatchAsync();
|
|
return default;
|
|
}
|
|
};
|
|
script.Chunks.Enqueue(ByteString.Empty);
|
|
fixture.Handler = script.DispatchAsync;
|
|
var client = new TemporaryFileTransferClient(fixture.ManagedSession, s_transfer);
|
|
using var destination = new MemoryStream();
|
|
Task<long> copying = client.CopyToAsync(default, destination, 3).AsTask();
|
|
try
|
|
{
|
|
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
|
Assert.That(copying.IsCompleted, Is.False);
|
|
Assert.That(fixture.Requests, Has.Count.EqualTo(2));
|
|
release.TrySetResult(true);
|
|
|
|
AggregateException failure = Assert.ThrowsAsync<AggregateException>(
|
|
async () => await copying.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false))!;
|
|
|
|
Assert.That(failure.InnerExceptions, Has.Count.EqualTo(2));
|
|
Assert.That(failure.InnerExceptions, Has.All.TypeOf<ServiceResultException>()
|
|
.With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.BadSessionIdInvalid));
|
|
Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod }));
|
|
Assert.That(fixture.Requests, Has.Count.EqualTo(2));
|
|
Assert.That(destination.Length, Is.Zero);
|
|
}
|
|
finally
|
|
{
|
|
release.TrySetResult(true);
|
|
if (recovery != null)
|
|
{
|
|
await recovery.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cancellation cannot discard a generated handle returned through a managed transport lease.
|
|
/// </summary>
|
|
[TestCase(false)]
|
|
[TestCase(true)]
|
|
public async Task ManagedCopyClosesHandleReturnedAfterCancellationAsync(bool delayedResponse)
|
|
{
|
|
await using var channels = new SessionChannelHarness();
|
|
await using Client.ManagedSession owner = await Client.ManagedSession.CreateAsync(
|
|
new ManagedSessionOptions { Endpoint = SessionChannelHarness.CreateEndpoint() },
|
|
channels.Configuration,
|
|
new DefaultSessionFactory(channels.Telemetry),
|
|
telemetry: channels.Telemetry,
|
|
channelManager: channels.Manager,
|
|
updateBeforeConnect: false).WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
|
|
Assert.That(owner.TransportChannel, Is.InstanceOf<IManagedTransportChannel>());
|
|
using var cancellation = new CancellationTokenSource();
|
|
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var script = new TransferScript
|
|
{
|
|
OnGenerate = async ct =>
|
|
{
|
|
Assert.That(ct, Is.EqualTo(cancellation.Token));
|
|
entered.TrySetResult(true);
|
|
if (delayedResponse)
|
|
{
|
|
await release.Task.ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
await cancellation.CancelAsync().ConfigureAwait(false);
|
|
}
|
|
}
|
|
};
|
|
ScriptedChannel transport = channels.CreatedChannels[0];
|
|
transport.RequestHandler = (request, ct) =>
|
|
request is CallRequest or TranslateBrowsePathsToNodeIdsRequest
|
|
? script.DispatchAsync(request, ct)
|
|
: new ValueTask<IServiceResponse>(transport.CreateResponse(request));
|
|
var client = new TemporaryFileTransferClient(owner, s_transfer);
|
|
using var destination = new MemoryStream();
|
|
Task<long> copying = client.CopyToAsync(default, destination, 3, cancellation.Token).AsTask();
|
|
try
|
|
{
|
|
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
|
if (delayedResponse)
|
|
{
|
|
await cancellation.CancelAsync().ConfigureAwait(false);
|
|
release.TrySetResult(true);
|
|
}
|
|
await Assert.ThatAsync(
|
|
() => copying.WaitAsync(TimeSpan.FromSeconds(10)),
|
|
Throws.InstanceOf<OperationCanceledException>()).ConfigureAwait(false);
|
|
|
|
Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod, MethodIds.FileType_Close }));
|
|
Assert.That(script.CloseToken.CanBeCanceled, Is.True);
|
|
Assert.That(script.CloseToken.IsCancellationRequested, Is.False);
|
|
Assert.That(script.CloseToken, Is.Not.EqualTo(cancellation.Token));
|
|
Assert.That(script.CloseDiscoveries, Is.Zero);
|
|
Assert.That(destination.Length, Is.Zero);
|
|
Assert.That(destination.CanWrite, Is.True);
|
|
Assert.That(owner.Connected, Is.True);
|
|
Assert.That(transport.CloseCount, Is.Zero);
|
|
Assert.That(transport.DisposeCount, Is.Zero);
|
|
}
|
|
finally
|
|
{
|
|
release.TrySetResult(true);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cleanup spends its independent deadline on the declaration Close call, not optional metadata discovery.
|
|
/// </summary>
|
|
[TestCase(false)]
|
|
[TestCase(true)]
|
|
public async Task CopyUsesDeclarationCloseWithoutDiscoveryAsync(bool managed)
|
|
{
|
|
await using var fixture = new SessionRequestContextTestContext();
|
|
var script = new TransferScript { RejectCloseDiscovery = true };
|
|
script.Chunks.Enqueue(ByteString.From([1, 2, 3]));
|
|
script.Chunks.Enqueue(ByteString.Empty);
|
|
fixture.Handler = script.DispatchAsync;
|
|
var client = new TemporaryFileTransferClient(
|
|
managed ? fixture.ManagedSession : fixture.InnerSession, s_transfer);
|
|
using var destination = new MemoryStream();
|
|
|
|
long copied = await client.CopyToAsync(default, destination, 3).ConfigureAwait(false);
|
|
|
|
Assert.That(copied, Is.EqualTo(3));
|
|
Assert.That(destination.ToArray(), Is.EqualTo(new byte[] { 1, 2, 3 }));
|
|
Assert.That(script.Methods, Is.EqualTo(
|
|
new[] { s_generateMethod, s_readMethod, s_readMethod, MethodIds.FileType_Close }));
|
|
Assert.That(script.CloseDiscoveries, Is.Zero);
|
|
Assert.That(script.CloseToken.CanBeCanceled, Is.True);
|
|
Assert.That(script.CloseToken.IsCancellationRequested, Is.False);
|
|
Assert.That(destination.CanWrite, Is.True);
|
|
Assert.That(fixture.InnerSession.Connected, Is.True);
|
|
fixture.Channel.Verify(channel => channel.CloseAsync(It.IsAny<CancellationToken>()), Times.Never);
|
|
fixture.Channel.Verify(channel => channel.Dispose(), Times.Never);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A non-Good generation result cannot hide a valid allocated handle from cleanup.
|
|
/// </summary>
|
|
[TestCase(false, false)]
|
|
[TestCase(false, true)]
|
|
[TestCase(true, false)]
|
|
public async Task CopyClosesHandleBeforeRejectingGenerationStatusAsync(bool serviceStatus, bool badStatus)
|
|
{
|
|
await using var fixture = new SessionRequestContextTestContext();
|
|
var script = new TransferScript();
|
|
StatusCode status = badStatus ? StatusCodes.BadUserAccessDenied : StatusCodes.Uncertain;
|
|
SetStatus(script.GenerationResponse, serviceStatus, status);
|
|
fixture.Handler = script.DispatchAsync;
|
|
var client = new TemporaryFileTransferClient(fixture.InnerSession, s_transfer);
|
|
using var destination = new MemoryStream();
|
|
|
|
ServiceResultException failure = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await client.CopyToAsync(default, destination, 3).ConfigureAwait(false))!;
|
|
|
|
Assert.That(failure.StatusCode, Is.EqualTo(status));
|
|
Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod, MethodIds.FileType_Close }));
|
|
Assert.That(destination.Length, Is.Zero);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Response-count and diagnostic errors are rejected only after retaining the returned file handle.
|
|
/// </summary>
|
|
[TestCase(false)]
|
|
[TestCase(true)]
|
|
public async Task CopyClosesHandleFromMalformedGenerationResponseAsync(bool extraResult)
|
|
{
|
|
await using var fixture = new SessionRequestContextTestContext();
|
|
var script = new TransferScript();
|
|
if (extraResult)
|
|
{
|
|
script.GenerationResponse.Results = [script.GenerationResponse.Results[0], new CallMethodResult()];
|
|
}
|
|
else
|
|
{
|
|
script.GenerationResponse.DiagnosticInfos = [new DiagnosticInfo(), new DiagnosticInfo()];
|
|
}
|
|
fixture.Handler = script.DispatchAsync;
|
|
var client = new TemporaryFileTransferClient(fixture.InnerSession, s_transfer);
|
|
using var destination = new MemoryStream();
|
|
|
|
ServiceResultException failure = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await client.CopyToAsync(default, destination, 3).ConfigureAwait(false))!;
|
|
|
|
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError));
|
|
Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod, MethodIds.FileType_Close }));
|
|
Assert.That(destination.Length, Is.Zero);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A rejected generation without a complete handle cannot trigger an invented Close.
|
|
/// </summary>
|
|
[TestCase(false)]
|
|
[TestCase(true)]
|
|
public async Task CopyRejectsGenerationWithoutInventingHandleAsync(bool uncertain)
|
|
{
|
|
await using var fixture = new SessionRequestContextTestContext();
|
|
var script = new TransferScript();
|
|
script.GenerationResponse.Results[0].StatusCode = uncertain ? StatusCodes.Uncertain : StatusCodes.Good;
|
|
script.GenerationResponse.Results[0].OutputArguments = [Variant.From(s_file)];
|
|
fixture.Handler = script.DispatchAsync;
|
|
var client = new TemporaryFileTransferClient(fixture.InnerSession, s_transfer);
|
|
using var destination = new MemoryStream();
|
|
|
|
ServiceResultException failure = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await client.CopyToAsync(default, destination, 3).ConfigureAwait(false))!;
|
|
|
|
Assert.That(failure.StatusCode,
|
|
Is.EqualTo(uncertain ? StatusCodes.Uncertain : StatusCodes.BadUnexpectedError));
|
|
Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod }));
|
|
Assert.That(destination.Length, Is.Zero);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generation rejection and cleanup failure remain independently visible in their original order.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task CopyPreservesGenerationAndCleanupFailuresAsync()
|
|
{
|
|
await using var fixture = new SessionRequestContextTestContext();
|
|
var script = new TransferScript();
|
|
script.GenerationResponse.Results[0].StatusCode = StatusCodes.Uncertain;
|
|
script.CloseResponse.Results[0].StatusCode = StatusCodes.BadUserAccessDenied;
|
|
fixture.Handler = script.DispatchAsync;
|
|
var client = new TemporaryFileTransferClient(fixture.InnerSession, s_transfer);
|
|
using var destination = new MemoryStream();
|
|
|
|
AggregateException failure = Assert.ThrowsAsync<AggregateException>(
|
|
async () => await client.CopyToAsync(default, destination, 3).ConfigureAwait(false))!;
|
|
|
|
Assert.That(failure.InnerExceptions, Has.Count.EqualTo(2));
|
|
Assert.That(failure.InnerExceptions[0], Is.TypeOf<ServiceResultException>()
|
|
.With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.Uncertain));
|
|
Assert.That(failure.InnerExceptions[1], Is.TypeOf<ServiceResultException>()
|
|
.With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.BadUserAccessDenied));
|
|
Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod, MethodIds.FileType_Close }));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Delaying generation validation does not weaken service or method validation for Read and Close.
|
|
/// </summary>
|
|
[TestCase(false, false)]
|
|
[TestCase(false, true)]
|
|
[TestCase(true, false)]
|
|
[TestCase(true, true)]
|
|
public async Task CopyKeepsReadAndCloseStatusValidationAsync(bool closeFails, bool serviceStatus)
|
|
{
|
|
await using var fixture = new SessionRequestContextTestContext();
|
|
var script = new TransferScript();
|
|
script.Chunks.Enqueue(closeFails ? ByteString.Empty : ByteString.From([1]));
|
|
SetStatus(closeFails ? script.CloseResponse : script.ReadResponse, serviceStatus, StatusCodes.Uncertain);
|
|
fixture.Handler = script.DispatchAsync;
|
|
var client = new TemporaryFileTransferClient(fixture.InnerSession, s_transfer);
|
|
using var destination = new MemoryStream();
|
|
|
|
ServiceResultException failure = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await client.CopyToAsync(default, destination, 3).ConfigureAwait(false))!;
|
|
|
|
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.Uncertain));
|
|
Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod, s_readMethod, MethodIds.FileType_Close }));
|
|
Assert.That(destination.Length, Is.Zero);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Assigns the selected service or method status without altering the returned output arguments.
|
|
/// </summary>
|
|
private static void SetStatus(CallResponse response, bool serviceStatus, StatusCode status)
|
|
{
|
|
if (serviceStatus)
|
|
{
|
|
response.ResponseHeader.ServiceResult = status;
|
|
}
|
|
else
|
|
{
|
|
response.Results[0].StatusCode = status;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Provides deterministic temporary-file service responses while recording actual method dispatches.
|
|
/// </summary>
|
|
private sealed class TransferScript
|
|
{
|
|
/// <summary>
|
|
/// Gets the generation response, whose metadata tests may corrupt independently of its handle.
|
|
/// </summary>
|
|
public CallResponse GenerationResponse { get; } = CreateResponse(
|
|
[Variant.From(s_file), Variant.From(kHandle), Variant.From(NodeId.Null)]);
|
|
|
|
/// <summary>
|
|
/// Gets the Read response, with output populated from the configured chunks.
|
|
/// </summary>
|
|
public CallResponse ReadResponse { get; } = CreateResponse([]);
|
|
|
|
/// <summary>
|
|
/// Gets the Close response.
|
|
/// </summary>
|
|
public CallResponse CloseResponse { get; } = CreateResponse([]);
|
|
|
|
/// <summary>
|
|
/// Gets the payload chunks; unexpected Read calls fail when none remain.
|
|
/// </summary>
|
|
public Queue<ByteString> Chunks { get; } = new();
|
|
|
|
/// <summary>
|
|
/// Gets the methods that reached the transport.
|
|
/// </summary>
|
|
public ArrayOf<NodeId> Methods => m_methods.ToArrayOf();
|
|
|
|
/// <summary>
|
|
/// Gets the number of unnecessary Close-metadata lookups.
|
|
/// </summary>
|
|
public int CloseDiscoveries { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Gets the cancellation token actually delivered to Close.
|
|
/// </summary>
|
|
public CancellationToken CloseToken { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets whether optional Close metadata is unavailable.
|
|
/// </summary>
|
|
public bool RejectCloseDiscovery { get; init; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets the action between generating a handle and returning its response.
|
|
/// </summary>
|
|
public Func<CancellationToken, ValueTask>? OnGenerate { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets the action before a generation-readiness response completes.
|
|
/// </summary>
|
|
public Func<CancellationToken, ValueTask>? OnStateRead { get; set; }
|
|
|
|
/// <summary>
|
|
/// Executes only the expected temporary-file protocol operations.
|
|
/// </summary>
|
|
public async ValueTask<IServiceResponse> DispatchAsync(IServiceRequest request, CancellationToken ct)
|
|
{
|
|
if (request is TranslateBrowsePathsToNodeIdsRequest translate)
|
|
{
|
|
Assert.That(translate.BrowsePaths.Count, Is.EqualTo(1));
|
|
string? name = translate.BrowsePaths[0].RelativePath.Elements[0].TargetName.Name;
|
|
if (name == BrowseNames.Close)
|
|
{
|
|
CloseDiscoveries++;
|
|
if (RejectCloseDiscovery)
|
|
{
|
|
throw new ServiceResultException(StatusCodes.BadTimeout, "Close metadata is unavailable.");
|
|
}
|
|
}
|
|
NodeId target = name switch
|
|
{
|
|
BrowseNames.GenerateFileForRead => s_generateMethod,
|
|
BrowseNames.Read => s_readMethod,
|
|
BrowseNames.Close => MethodIds.FileType_Close,
|
|
BrowseNames.CurrentState => s_stateId,
|
|
_ => throw new AssertionException("Unexpected path: " + name)
|
|
};
|
|
return new TranslateBrowsePathsToNodeIdsResponse
|
|
{
|
|
ResponseHeader = new ResponseHeader(),
|
|
Results =
|
|
[
|
|
new BrowsePathResult
|
|
{
|
|
Targets =
|
|
[
|
|
new BrowsePathTarget { TargetId = target, RemainingPathIndex = uint.MaxValue }
|
|
]
|
|
}
|
|
]
|
|
};
|
|
}
|
|
if (request is ReadRequest read)
|
|
{
|
|
Assert.That(read.NodesToRead.Count, Is.EqualTo(1));
|
|
Assert.That(read.NodesToRead[0].NodeId, Is.EqualTo(s_stateId));
|
|
if (OnStateRead != null)
|
|
{
|
|
await OnStateRead(ct).ConfigureAwait(false);
|
|
}
|
|
return new ReadResponse
|
|
{
|
|
ResponseHeader = new ResponseHeader(),
|
|
Results = [new DataValue(Variant.From(ObjectIds.FileTransferStateMachineType_ReadTransfer))]
|
|
};
|
|
}
|
|
Assert.That(request, Is.TypeOf<CallRequest>());
|
|
var call = (CallRequest)request;
|
|
Assert.That(call.MethodsToCall.Count, Is.EqualTo(1));
|
|
CallMethodRequest method = call.MethodsToCall[0];
|
|
m_methods.Add(method.MethodId);
|
|
if (method.MethodId == s_generateMethod)
|
|
{
|
|
Assert.That(method.ObjectId, Is.EqualTo(s_transfer));
|
|
if (OnGenerate != null)
|
|
{
|
|
await OnGenerate(ct).ConfigureAwait(false);
|
|
}
|
|
return GenerationResponse;
|
|
}
|
|
Assert.That(method.ObjectId, Is.EqualTo(s_file));
|
|
Assert.That(method.InputArguments[0].TryGetValue(out uint handle), Is.True);
|
|
Assert.That(handle, Is.EqualTo(kHandle));
|
|
if (method.MethodId == s_readMethod)
|
|
{
|
|
Assert.That(method.InputArguments.Count, Is.EqualTo(2));
|
|
ReadResponse.Results[0].OutputArguments = [Variant.From(Chunks.Dequeue())];
|
|
return ReadResponse;
|
|
}
|
|
Assert.That(method.MethodId, Is.EqualTo(MethodIds.FileType_Close));
|
|
Assert.That(method.InputArguments.Count, Is.EqualTo(1));
|
|
CloseToken = ct;
|
|
return CloseResponse;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates one successful method response.
|
|
/// </summary>
|
|
private static CallResponse CreateResponse(ArrayOf<Variant> outputs)
|
|
{
|
|
return new CallResponse
|
|
{
|
|
ResponseHeader = new ResponseHeader(),
|
|
Results = [new CallMethodResult { OutputArguments = outputs }]
|
|
};
|
|
}
|
|
|
|
private readonly List<NodeId> m_methods = [];
|
|
}
|
|
|
|
private const uint kHandle = 7;
|
|
private static readonly NodeId s_transfer = new(1000u);
|
|
private static readonly NodeId s_file = new(1001u);
|
|
private static readonly NodeId s_generateMethod = new(1002u);
|
|
private static readonly NodeId s_readMethod = new(1003u);
|
|
private static readonly NodeId s_stateMachine = new(1004u);
|
|
private static readonly NodeId s_stateId = new(1005u);
|
|
}
|
|
}
|