mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-17 05:11:13 +08:00
68f062eecc
## Description
Six small changes to the per-request path from the o6 benchmark gap
report (S1–S6), chosen from the allocation profile of the o6 read
benchmark (`AsyncCustomNodeManager`, Server GC, 10 clients × 100
single-value Reads in flight). Each change is its own commit. The
receive loop, its threading and its dispatch are not touched; there are
no wire, public API or generator changes.
### Results
Paired A/B runs, builds alternated per round, 5 rounds unless noted,
medians (min..max). Baseline: master d40843c23 with #4787, #4788 and
#4805 (all three merged since; this branch is rebased on master). Each
item was measured on its own on the baseline, and all six together. The
machine is shared with other sessions: a run during which another test
host or load harness ran was discarded and repeated. Allocated bytes per
request are deterministic; CPU per request is best read from the runs
pinned to 4 logical processors (`--server-affinity 0xF`, the 4 Zen 5
cores).
**Allocated bytes per request** (o6read 10 × 100, pinned):
| Node manager, security | baseline | S1 | S2 | S3 | S4 | S5 | S6 | all
|
|---|---|---|---|---|---|---|---|---|
| async, plain | 4,219 B | 4,114 (−2.5 %) | 3,631 (−13.9 %) | 4,057
(−3.8 %) | 4,123 (−2.3 %) | 4,099 (−2.8 %) | 3,511 (−16.8 %) | **2,411
(−42.9 %)** |
| async, encrypted | 4,267 B | −2.6 % | −13.8 % | −3.7 % | −2.4 % | −3.1
% | −16.5 % | **2,465 (−42.2 %)** |
| `CustomNodeManager2`, plain | 5,407 B | −2.1 % | −5.1 % | −3.0 % |
−1.8 % | −2.5 % | −12.9 % | **3,910 (−27.6 %)** |
| `CustomNodeManager2`, encrypted | 5,457 B | −2.0 % | −5.1 % | −2.9 % |
−1.9 % | −2.4 % | −12.9 % | **3,963 (−27.4 %)** |
**CPU per request, pinned to 4 logical processors:**
| Node manager, security | baseline | all six | each item alone |
|---|---|---|---|
| async, plain | 28.3 µs (27.8..29.8) | **27.1 µs (25.6..27.7), −4.2 %**
| −2.7 .. +1.9 % |
| async, encrypted | 31.5 µs (30.8..32.9) | 30.6 µs (30.1..40.1), −2.8 %
| −2.8 .. +0.8 % |
| `CustomNodeManager2`, plain | 34.0 µs (33.1..36.6) | **31.2 µs
(30.5..34.1), −8.1 %** | −5.0 .. +2.5 % |
| `CustomNodeManager2`, encrypted | 35.8 µs (35.0..38.9) | **34.3 µs
(33.5..35.2), −4.1 %** | −1.0 .. +3.8 % |
Garbage collections in the pinned plain run (300,000 requests): gen0 19
→ 12, pause 85 → 50 ms.
**After review (S6 with immutable codecs):** the tables above were
measured with a decoder and an encoder reset per message. Keeping
`BinaryDecoder` and `BinaryEncoder` immutable, so every message gets new
ones, costs about 214 bytes per Read: all six together allocate 2,625 B
per Read instead of 2,411 B in the pinned plain run (2 runs), −37.8 %
against the baseline.
**24 cores and the managed client** (all six against the baseline; the
managed scenarios 3 rounds):
| Scenario | baseline | all six |
|---|---|---|
| o6read 10 × 100, 24 cores: req/s | 296.3k (283.4..305.4k) | 302.2k
(245.0..323.0k) |
| same: allocated / request | 4,169 B | 2,349 B (−43.7 %) |
| o6read 10 × 1000, 24 cores: req/s | 297.4k (284.2..309.8k) | 295.6k
(269.6..311.5k) |
| same: allocated / request | 4,196 B | 2,373 B (−43.4 %) |
| `read --nodes 1`, 20 sessions: req/s, CPU / request | 259.3k, 40.2 µs
| 271.4k, 38.9 µs (−3.2 %) |
| same: allocated / request | 4,172 B | 2,444 B (−41.4 %) |
| `read --nodes 1`, 200 sessions: req/s, CPU / request | 257.1k, 36.7 µs
| 283.6k, **33.1 µs (−9.8 %)** |
| same: allocated / request | 4,041 B | 2,354 B (−41.7 %) |
| `read --nodes 100`: CPU / request | 226.1 µs | 214.9 µs (−5.0 %) |
| same: allocated / request | 72,379 B | 52,046 B (−28.1 %; S2 alone
−26.3 %) |
| `browse`: CPU / request (6 rounds) | 147.9 µs | 148.8 µs (+0.6 %) |
| same: allocated / request | 38,234 B | 32,603 B (−14.7 %) |
| `sub`: allocated / notification | 126 B | 121 B (−4.0 %) |
**Reading the CPU numbers.** Every item lowers the allocation per
request by a deterministic amount, and the savings add up (the six items
alone sum to −42.1 % against −42.9 % together). CPU per request follows
only partly: each item alone changes it within the run-to-run noise (±3
%); all six together lower it by 3–10 %, with the min..max ranges
separated in the pinned plain async and the `CustomNodeManager2`
encrypted runs and in `read --nodes 1` with 200 sessions. With Server GC
on 4 cores, garbage collection is a small share of the CPU, so 40 %
fewer bytes buy a few percent of CPU. At 24 cores throughput and CPU per
request are dominated by where the scheduler places the threads (Zen 5
and Zen 5c cores) and do not resolve a change of this size.
Allocation trace (o6read 10 × 100, first `Opc.Ua` frame): gone are the
`ResponseHeader` from `ServerBase.CreateResponse`, `Dictionary<NodeId,
NodeState>` (`AsyncCustomNodeManager.ReadAsync`), one of two
`NodeHandle`s, `Variant[]`/`uint[]` (permission attributes),
`List<DataValue>`/`List<DiagnosticInfo>`/`DiagnosticInfo[]`,
`ArrayOf<ExtensionObject>` (`Variant.Copy`), `RolePermissionType[]`,
`SecureChannelContext`, `PendingAdmission`, `System.Action`,
`RequestExecutionScope`, the `DequeueAsync` state machine box,
`MemoryStream`/`BinaryReader` of the decoder, `ArraySegmentStream`,
`BinaryWriter` and one `BufferCollection`.
## Changes
**S1: one `ResponseHeader` per response.** The generated response
constructors already create a default `ResponseHeader`; the services
replaced it with the one `ServerBase.CreateResponse` allocated. The
services that construct their response now offer its default header, and
the base `CreateResponse(RequestHeader, StringTable)` fills it. The
offer goes through a field of the thread, so the protected virtual keeps
its signature: an override that does not call the base returns its own
header as before, and a re-entrant call never gets the outer offer. The
services that receive their response from the subscription manager keep
the previous path. The third `ResponseHeader` in the profile is the
generated `Reuse()` resetting the response after it was sent. It stays:
`LeasedPublishResponse` releases its notification lease there, and the
reset is generated code.
**S2: a single-node Read without collections.**
- The dispatcher fills arrays instead of lists that were copied into the
response (the node managers fill them by index), and allocates the
operation diagnostics only for the first one.
- `AsyncCustomNodeManager.ReadAsync` creates its per-request node cache
only for a request with several nodes, or when a node must be validated.
- The permission check already resolves every node through
`GetManagerHandleAsync`, whose handle "does not require the NodeId to be
validated again". The Read service passes that handle on in
`ReadValueId.Handle` when it is an in-memory node of an
`AsyncCustomNodeManager` that was validated when it was returned, and
the node manager reads through it instead of looking the node up and
allocating a second handle. A handle passed on for an earlier read of
the same `ReadValueId` is cleared.
- The permission check without an attribute cache reads the three
permission attributes into arrays of the thread.
**S3: no copy of empty arrays.** `Variant.Copy` deep-copies arrays of
ExtensionObjects, DataValues and Variants because the caller can change
their elements. An empty array has no element to change but was copied
into a new boxed empty array. The Read service copies the namespace's
DefaultRolePermissions and DefaultUserRolePermissions on every
permission check (the variables' `CopyOnRead` policy); they are empty
unless roles are configured. `TryGetStructure` of an empty array returns
the empty array instead of a new one. Non-empty arrays, scalars and
matrices are copied as before.
**S4: one `SecureChannelContext` per channel token.**
`TcpTransportListener.OnRequestReceived` created a context per request,
although it only holds values that stay the same for the life of a
token. The channel keeps the last context with the values it was created
from and returns it while they are the same references; a renewed token
(new thumbprint, rotated certificate) or a transport reconnected to the
channel gets a new one. The isolation provider (#4787) and the session
binding check compare contexts by value.
**S5: fewer bookkeeping objects per request.**
- A request inside its service dispatch scope, which is every request
the server dispatches, needs no `RequestExecutionScope`: its
`OperationContext` completes the request itself, exactly once, when it
is disposed.
- `FairRequestQueue.TryEnqueue` owns the unpublished entry in a local
instead of a `PendingAdmission` wrapper, and registers the entry's
cancellation with a static callback instead of a delegate per request.
- `DequeueAsync` pools its state machine on .NET.
**S6: fewer codec objects per message.** Encoding a message is
synchronous, so a thread encodes one message at a time. The decoder and
the encoder stay immutable: every message gets new ones (see the
review).
- `EncodeSymmetricMessage` writes the body to the `ArraySegmentStream`
of the thread, reset per message, through a new `BinaryEncoder` that
borrows the `BinaryWriter` the thread keeps over that stream (closing or
disposing such an encoder flushes the writer but leaves it and the
stream open). The stream is given back once it handed its buffers over;
a body that fails to encode releases the stream's buffers, and the
stream is not reused. The stream is per thread, not per channel: since
#4782 the producers encode the responses of a channel concurrently and
only sealing runs in the writer.
- A response (client) in one chunk is decoded from that chunk's buffer,
as the server decodes requests; the client used a stream over the chunk
list for every response.
- A `BinaryDecoder` over a buffer creates its `MemoryStream` and
`BinaryReader` only when `BaseStream` exposes the stream, and reports
`Position` without exposing it. `DecodeMessage` reads `Position`, which
exposed the stream for every message and made every later read
synchronize its position with it.
- `ArraySegmentStream.GetBuffers` hands over the stream's own collection
instead of copying it; a message in one chunk is reassembled into a
collection sized for one.
### Not done
- **Skipping the reset of a sent response** (the third
`ResponseHeader`): `LeasedPublishResponse` releases its notification
lease in `Reuse()`.
- **Pooling `RequestLifetime`, `OperationContext` and
`EndpointIncomingRequest`:** they leave the stack. `RequestLifetime` is
public and handed to every service handler, and its token to node
managers. `EndpointIncomingRequest` is handed to the overridable
`ScheduleIncomingRequest`.
- **Pooling the fair queue `Entry`:** its cancellation registration and
the stop path can still reach it after it was released.
- **A struct token for the isolation lease:** leases are returned as
`IDisposable` through `IServerResourceIsolationProvider.TryAcquire`.
- **Reading the permission defaults without a copy when roles are
configured:** not measurable with the o6 server, which configures none.
## Behaviour notes
- **S1:** an override of `ServerBase.CreateResponse(RequestHeader,
StringTable)` that calls the base gets the response's default header
instead of a new one, the header the response carried anyway.
- **S2:**
- The node managers receive the Read result lists as arrays (`IList<T>`,
fixed size). The lists were pre-filled per item; adding to them was
never part of the contract.
- `ReadValueId.Handle` of a Read request holds the passed-on
`NodeHandle` during the call.
- **S4:** the requests of one token share one `SecureChannelContext`.
`UpstreamIdentity` has a public setter; the TCP transport never sets it,
but application code that set it on the context of a TCP request would
now change it for the token's later requests as well.
- **S6:**
- Each thread that encodes channel messages keeps one stream and writer
(a few hundred bytes), with a reference to the buffer manager of the
last channel it served.
- `BinaryDecoder.Position` of a buffer decoder no longer exposes
`BaseStream`.
## Testing
- **New tests:**
- S1, `StandardServerResponseStringTableTests` and
`ReferenceServerTests`:
- the offered header is filled with every field (timestamp, request
handle, service result, no diagnostics, string table);
- an override without the base keeps its own header;
- an override with the base fills the offered one;
- a re-entrant response never shares it;
- Read, Write and Browse responses keep every header field;
- a failed Read throws its status.
- S2, `AsyncCustomNodeManagerTests.ReadRouting` (all three node manager
fixtures):
- a single-node read uses the handle of the validation and returns what
a multi-node read returns;
- a handle passed on for an earlier read is not reused (another node,
removed node, no session);
- operation diagnostics keep one entry per node and are absent when not
requested.
- S3, `VariantCoverageTests`: empty arrays of each kind are shared; a
null array stays null; `TryGetStructure` of an empty, a null and a
one-element array.
- S4, `TcpListenerChannelTests`: the context is shared until the
thumbprint or the transport changes.
- S5, `RequestManagerTests`:
- an attached request completes once, however often its context is
disposed;
- the ambient request id is restored outside a dispatch scope;
- a stress test of attach, double dispose from two threads, cancellation
and drains.
- S6:
- `BinaryEncoderTests`: encoders borrowing one writer encode like new
encoders, and leave the writer and the stream open; null arguments;
- `BinaryDecoderTests`: `BaseStream` and `Position` of a buffer decoder;
range checks;
- `ArraySegmentStreamTests`: `GetBuffers` hands over and the stream
continues; a read stream still returns a copy; `Reset` and
`ReleaseBuffers`.
- **Run locally (net10.0, all pass):** Opc.Ua.Types.Tests,
Opc.Ua.Core.Tests, Opc.Ua.Core.Encoders.Tests, Opc.Ua.Server.Tests,
Opc.Ua.Client.Tests, Opc.Ua.Sessions.Tests, Opc.Ua.Subscriptions.Tests,
Opc.Ua.Redundancy.Server.Tests, Opc.Ua.Redundancy.Client.Tests,
Opc.Ua.Stress.Tests Contract + Integration. net48 builds.
- **Measured with:** `perf/ServerLoadHarness` scenarios `o6read` (o6
harness, not part of this PR), `read`, `browse`, `sub`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
280 lines
11 KiB
C#
280 lines
11 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 NUnit.Framework;
|
|
using Opc.Ua.Tests;
|
|
|
|
namespace Opc.Ua.Server.Tests
|
|
{
|
|
/// <summary>
|
|
/// The response header of a request that wrote no diagnostic strings is built with an
|
|
/// empty string table of the thread; overrides of the response hook must not leak
|
|
/// strings into later responses through it.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("StandardServer")]
|
|
[Parallelizable]
|
|
public class StandardServerResponseStringTableTests
|
|
{
|
|
/// <summary>
|
|
/// A request without diagnostic strings gets an empty, not a null, string table.
|
|
/// </summary>
|
|
[Test]
|
|
public void ResponseWithoutStringsHasEmptyStringTable()
|
|
{
|
|
using var server = new HookServer();
|
|
|
|
ResponseHeader header = server.CreateResponse(new RequestHeader { RequestHandle = 7 }, NewContext());
|
|
|
|
Assert.That(header.RequestHandle, Is.EqualTo(7u));
|
|
Assert.That(header.StringTable.IsNull, Is.False);
|
|
Assert.That(header.StringTable.Count, Is.Zero);
|
|
}
|
|
|
|
/// <summary>
|
|
/// An override that adds strings to the table and then throws must not hand those
|
|
/// strings to the next response created on the thread.
|
|
/// </summary>
|
|
[Test]
|
|
public void ThrowingOverrideDoesNotLeakStringsIntoTheNextResponse()
|
|
{
|
|
using var server = new HookServer();
|
|
server.Hook = (header, strings) =>
|
|
{
|
|
strings.Append("leaked");
|
|
throw new InvalidOperationException("hook failed");
|
|
};
|
|
|
|
Assert.That(
|
|
() => server.CreateResponse(new RequestHeader(), NewContext()),
|
|
Throws.InvalidOperationException);
|
|
|
|
server.Hook = null;
|
|
ResponseHeader next = server.CreateResponse(new RequestHeader(), NewContext());
|
|
Assert.That(next.StringTable.Count, Is.Zero);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Strings an override adds go into its own response only.
|
|
/// </summary>
|
|
[Test]
|
|
public void StringsAddedByOverrideStayInItsResponse()
|
|
{
|
|
using var server = new HookServer();
|
|
server.Hook = (header, strings) =>
|
|
{
|
|
strings.Append("mine");
|
|
return server.BaseCreateResponse(header, strings);
|
|
};
|
|
|
|
ResponseHeader first = server.CreateResponse(new RequestHeader(), NewContext());
|
|
Assert.That(first.StringTable.Count, Is.EqualTo(1));
|
|
Assert.That(first.StringTable[0], Is.EqualTo("mine"));
|
|
|
|
server.Hook = null;
|
|
ResponseHeader next = server.CreateResponse(new RequestHeader(), NewContext());
|
|
Assert.That(next.StringTable.Count, Is.Zero);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A response created while the hook runs (re-entrantly) gets a table of its own.
|
|
/// </summary>
|
|
[Test]
|
|
public void ReentrantResponseGetsItsOwnTable()
|
|
{
|
|
using var server = new HookServer();
|
|
var tables = new List<StringTable>();
|
|
server.Hook = (header, strings) =>
|
|
{
|
|
tables.Add(strings);
|
|
if (tables.Count == 1)
|
|
{
|
|
strings.Append("outer");
|
|
ResponseHeader inner = server.CreateResponse(new RequestHeader(), NewContext());
|
|
Assert.That(inner.StringTable.Count, Is.Zero);
|
|
}
|
|
return server.BaseCreateResponse(header, strings);
|
|
};
|
|
|
|
ResponseHeader outer = server.CreateResponse(new RequestHeader(), NewContext());
|
|
|
|
Assert.That(tables, Has.Count.EqualTo(2));
|
|
Assert.That(tables[1], Is.Not.SameAs(tables[0]));
|
|
Assert.That(outer.StringTable.Count, Is.EqualTo(1));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A request that wrote diagnostic strings gets them in its response.
|
|
/// </summary>
|
|
[Test]
|
|
public void ResponseCarriesTheStringsOfTheRequest()
|
|
{
|
|
using var server = new HookServer();
|
|
OperationContext context = NewContext();
|
|
context.StringTable.Append("diagnostic");
|
|
|
|
ResponseHeader header = server.CreateResponse(new RequestHeader(), context);
|
|
|
|
Assert.That(header.StringTable.Count, Is.EqualTo(1));
|
|
Assert.That(header.StringTable[0], Is.EqualTo("diagnostic"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The default header a service offers is filled and returned instead of a new one,
|
|
/// with every field the response header had before.
|
|
/// </summary>
|
|
[Test]
|
|
public void OfferedHeaderIsFilledWithEveryField()
|
|
{
|
|
using var server = new HookServer();
|
|
var offered = new ResponseHeader();
|
|
OperationContext context = NewContext();
|
|
context.StringTable.Append("diagnostic");
|
|
DateTimeUtc before = DateTimeUtc.Now;
|
|
|
|
ResponseHeader header = server.CreateResponse(new RequestHeader { RequestHandle = 9 }, context, offered);
|
|
|
|
Assert.That(header, Is.SameAs(offered));
|
|
Assert.That(header.RequestHandle, Is.EqualTo(9u));
|
|
Assert.That(header.Timestamp, Is.GreaterThanOrEqualTo(before));
|
|
Assert.That(header.Timestamp, Is.LessThanOrEqualTo(DateTimeUtc.Now));
|
|
Assert.That(header.ServiceResult, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(header.ServiceDiagnostics, Is.Null);
|
|
Assert.That(header.StringTable.Count, Is.EqualTo(1));
|
|
Assert.That(header.StringTable[0], Is.EqualTo("diagnostic"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// An override that does not call the base keeps returning its own header, the
|
|
/// offered header stays untouched, and a later call does not get it.
|
|
/// </summary>
|
|
[Test]
|
|
public void OverrideWithoutBaseKeepsItsOwnHeader()
|
|
{
|
|
using var server = new HookServer();
|
|
var own = new ResponseHeader { RequestHandle = 5 };
|
|
server.Hook = (header, strings) => own;
|
|
var offered = new ResponseHeader();
|
|
|
|
ResponseHeader result = server.CreateResponse(new RequestHeader { RequestHandle = 9 }, NewContext(), offered);
|
|
|
|
Assert.That(result, Is.SameAs(own));
|
|
Assert.That(offered.RequestHandle, Is.Zero);
|
|
Assert.That(offered.Timestamp, Is.EqualTo(DateTimeUtc.MinValue));
|
|
|
|
server.Hook = null;
|
|
ResponseHeader next = server.CreateResponse(new RequestHeader { RequestHandle = 10 }, NewContext());
|
|
Assert.That(next, Is.Not.SameAs(offered));
|
|
Assert.That(next.RequestHandle, Is.EqualTo(10u));
|
|
}
|
|
|
|
/// <summary>
|
|
/// An override that calls the base gets the offered header and may change it.
|
|
/// </summary>
|
|
[Test]
|
|
public void OverrideCallingBaseFillsTheOfferedHeader()
|
|
{
|
|
using var server = new HookServer();
|
|
server.Hook = (header, strings) =>
|
|
{
|
|
ResponseHeader created = server.BaseCreateResponse(header, strings);
|
|
created.ServiceResult = StatusCodes.GoodCallAgain;
|
|
return created;
|
|
};
|
|
var offered = new ResponseHeader();
|
|
|
|
ResponseHeader result = server.CreateResponse(new RequestHeader { RequestHandle = 3 }, NewContext(), offered);
|
|
|
|
Assert.That(result, Is.SameAs(offered));
|
|
Assert.That(result.RequestHandle, Is.EqualTo(3u));
|
|
Assert.That(result.ServiceResult, Is.EqualTo(StatusCodes.GoodCallAgain));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A response created while the hook runs (re-entrantly) never shares the header
|
|
/// offered for the outer response.
|
|
/// </summary>
|
|
[Test]
|
|
public void ReentrantResponseDoesNotShareTheOfferedHeader()
|
|
{
|
|
using var server = new HookServer();
|
|
ResponseHeader? inner = null;
|
|
server.Hook = (header, strings) =>
|
|
{
|
|
if (inner == null)
|
|
{
|
|
server.Hook = null;
|
|
inner = server.CreateResponse(new RequestHeader { RequestHandle = 2 }, NewContext());
|
|
server.Hook = (h, s) => server.BaseCreateResponse(h, s);
|
|
}
|
|
return server.BaseCreateResponse(header, strings);
|
|
};
|
|
var offered = new ResponseHeader();
|
|
|
|
ResponseHeader outer = server.CreateResponse(new RequestHeader { RequestHandle = 1 }, NewContext(), offered);
|
|
|
|
Assert.That(inner, Is.Not.Null);
|
|
Assert.That(inner, Is.Not.SameAs(outer));
|
|
Assert.That(inner!.RequestHandle, Is.EqualTo(2u));
|
|
Assert.That(outer.RequestHandle, Is.EqualTo(1u));
|
|
}
|
|
|
|
private static OperationContext NewContext()
|
|
{
|
|
return new OperationContext(new RequestHeader(), null, RequestType.Read, RequestLifetime.None);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A server whose response hook a test replaces.
|
|
/// </summary>
|
|
private sealed class HookServer : StandardServer
|
|
{
|
|
public HookServer()
|
|
: base(NUnitTelemetryContext.Create())
|
|
{
|
|
}
|
|
|
|
public Func<RequestHeader, StringTable, ResponseHeader>? Hook { get; set; }
|
|
|
|
public ResponseHeader BaseCreateResponse(RequestHeader requestHeader, StringTable stringTable)
|
|
{
|
|
return base.CreateResponse(requestHeader, stringTable);
|
|
}
|
|
|
|
protected override ResponseHeader CreateResponse(RequestHeader requestHeader, StringTable stringTable)
|
|
{
|
|
Func<RequestHeader, StringTable, ResponseHeader>? hook = Hook;
|
|
return hook != null ? hook(requestHeader, stringTable) : base.CreateResponse(requestHeader, stringTable);
|
|
}
|
|
}
|
|
}
|
|
}
|