Files
UA-.NETStandard/tests/Opc.Ua.Client.Tests/FileSystem/TemporaryFileTransferClientBoundedReadTests.cs
Marc Schier e78d36ce4b Client/server transfer, WSS, and hosting fixes (#4802)
# 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.
2026-10-15 19:29:42 +02:00

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);
}
}