Skip to content

Commit ce2ccc7

Browse files
authored
Fix transport buffer pooling, limiting, and in-process capture (#3994)
# Description Fixes transport-buffer pool amplification when `TransportQuotas.MaxBufferSize` is an exact power of two, adds injectable buffer-manager policies and a process-wide memory limiter, and fixes in-process pcap capture of maximum-size UASC chunks. `BufferManager` previously reserved an ownership cookie after the usable data in every build. A 65,536-byte limit therefore requested 65,537 bytes from `ArrayPool<byte>.Shared`, selecting the 131,072-byte bucket. The compatibility `BufferManager` is now a facade over `IBufferManager`; Release defaults to `FastBufferManager`, Debug defaults to `CookieBufferManager`, and explicit `TRACK_MEMORY` builds use `TracingBufferManager`. `IBufferManagerFactory` and `BufferManagerFactoryOptions` are registered through dependency injection and wired through raw TCP, HTTPS/WSS, and Kestrel channel/listener creation while preserving existing direct constructors. Applications can replace the factory or explicitly select fast, cookie, or tracing behavior. `LimitingBufferManager` wraps any implementation and uses a factory-shared `BufferManagerMemoryLimiter` to cap actual outstanding bytes across all managers created by that factory. It blocks synchronous rents without holding manager locks, supports cancellation on transport I/O paths, accounts for actual pool bucket sizes, rejects impossible or synchronous re-entrant rents safely, and protects accounting from failed, foreign, and duplicate returns. The client and accepted-server TCP transports also retained raw quota values. The fix carries bucket-safe limits into transport creation and reconnects, while preserving the active connection’s negotiated receive limit. Configurable transports receive updated limits during server reconnect, and every channel independently rejects and faults/reconnects on chunks above the negotiated limit. Maximum-size UASC chunks exceeded the synthetic IPv4 total-length field once TCP/IP headers were added. The capture path now segments those chunks into sequence-aware synthetic TCP packets sized for both IPv4 and the pcap snap length. Every capturing transport receives a stable process-unique synthetic flow identity that is preserved in the full TCP tuple and independent sequence state. Capture queuing remains non-blocking and queues or drops complete UASC chunks atomically; sequence space is reserved before bounded frame admission so dropped chunks remain visible as TCP gaps, while packetization stays in the single writer. Key-material snapshots use a separate prioritized unbounded queue and are drained and disposed reliably during shutdown. This change includes: - `IBufferManager`, `IBufferManagerFactory`, factory options, and compatibility facade; - fast, cookie, allocation-tracing, and memory-limiting implementations; - shared DI factory wiring for raw TCP, HTTPS/WSS, and Kestrel transports; - Debug/Release/TRACK_MEMORY implementation selection; - cancellation-aware transport rents and no-loss in-process receive behavior; - deterministic ownership, limiter, bucket-size, reconnect, oversized-chunk, cancellation, and DI tests; - explicit subscription diagnostics using `ArrayPoolEventSource`; - pcap framing, sequence-space, atomic-queue, and reassembly regression tests; - updated benchmark and dependency-injection documentation. Measured with 255 and 257 Publish queues plus a slow-consumer scenario, configured values of both 65,535 and 65,536 used only 65,536-byte arrays. Every observed rent had a matching return, outstanding buffers returned to zero, and no Gen 2 collections occurred. The 500-session baseline also passed for `opc.tcp`, `https`, and `opc.https`, with all sessions receiving notifications. ## Related Issues - Fixes #3988 ## Checklist - [x] 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. - [x] 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.
1 parent c250db6 commit ce2ccc7

52 files changed

Lines changed: 7883 additions & 468 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Docs/Benchmarks.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,16 @@ The numbers below were measured on an **Intel Xeon W-2235 (6 physical cores / 12
427427

428428
(*) These figures are hardware- and load-dependent and are directional; they improve on dedicated hardware where the client and server run on separate machines. With `ServerConfiguration.DecoupleHeldPublishRequests` on (the default) and `MaxRequestThreadCount` sized to the active establishment concurrency (~200) rather than to the session count, this 6-core machine cleanly established every session and delivered 100 % of notifications up to **~4000** concurrent sessions. At 10000 it tips into a secure-channel connect storm (repeated handshake aborts/retries and tens of thousands of channel attempts for the target) and does not establish the full set within the connect budget - so on this hardware establishment, not notification delivery, is the ceiling; more cores push it higher. When sessions establish but do not receive notifications the last column instead reads `No (N drops)`, where `N` is the number of sessions that did not receive notifications within the window. See [Server Session Scalability](ServerScalability.md) for the code-referenced analysis and the built-in controls for degrading gracefully under load.
429429

430+
### Subscription transport buffer pooling
431+
432+
`BufferManager` previously stored a one-byte ownership cookie after the usable transport data in all builds. Configuring `TransportQuotas.MaxBufferSize` to an exact power of two such as 65,536 could therefore call `ArrayPool<byte>.Rent(65,537)`, selecting the 131,072-byte bucket. The cookie is now enabled in Debug builds (and explicit `TRACK_MEMORY` diagnostic builds) but removed from normal Release builds; the secure-channel normalization still keeps data plus any active diagnostic cookie in the same bucket.
433+
434+
Channels now normalize their advertised send and receive limits to the largest protocol-safe value that keeps the data plus cookie in the same `ArrayPool` bucket (while preserving the OPC UA 8,192-byte minimum), and both TCP transport creation paths receive that normalized limit. A configured value of either 65,535 or 65,536 consequently uses 65,536-byte arrays instead of 131,072-byte arrays.
435+
436+
The explicit net10.0 harness `SubscriptionArrayPoolDiagnosticsLoadTests` (`Tests/Opc.Ua.Sessions.Tests/SubscriptionArrayPoolDiagnosticsLoadTests.cs`) measures real subscription traffic with 255 and 257 session Publish queues (immediately below and above the parallel Publish threshold) plus a slow-consumer case. Across both configured buffer sizes, all measured scenarios used only the 65,536-byte bucket, rents and returns balanced exactly after quiescence, outstanding buffers returned to zero, peak outstanding buffers stayed between 528 and 536, and the measurement windows triggered no Gen 2 collections. The 500-session regression baseline also completed for `opc.tcp`, `https`, and `opc.https`, with all 500 sessions receiving notifications.
437+
438+
The net10.0 in-process short `BufferManagerBenchmarks` run completed 256 `BufferManager.TakeBuffer`/`ReturnBuffer` pairs in 56.18 us versus 52.52 us for direct `ArrayPool<byte>.Shared` use, with no managed allocation reported for either pooled path. These short-run timings are directional; the subscription harness is the authoritative ownership and bucket-size check.
439+
430440
### Sizing and configuration
431441

432442
* `MaxSessionCount` (default 100) caps the concurrent open sessions; size `MaxChannelCount` (one channel per session) and `MaxSubscriptionCount` above the target session count.

Docs/DependencyInjection.md

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,14 @@ lifetime, certificate setup, and Ctrl+C / SIGTERM handling.
8585

8686
## Root: `services.AddOpcUa()`
8787

88-
`services.AddOpcUa()` is the only entry point. It does two things:
88+
`services.AddOpcUa()` is the only entry point. It does three things:
8989

9090
1. Registers `ITelemetryContext` as a `ServiceProviderTelemetryContext`
9191
singleton (via `TryAddSingleton`, so any prior user registration
9292
wins).
93-
2. Returns an `IOpcUaBuilder` whose `.Services` property exposes the
93+
2. Registers the default `IBufferManagerFactory` and
94+
`BufferManagerFactoryOptions` singletons (also via `TryAddSingleton`).
95+
3. Returns an `IOpcUaBuilder` whose `.Services` property exposes the
9496
underlying `IServiceCollection` for advanced scenarios.
9597

9698
```csharp
@@ -114,6 +116,35 @@ services.AddOpcUa()
114116
.AddNodeManager<MyNodeManagerFactory>();
115117
```
116118

119+
### Buffer managers
120+
121+
Transport listeners and channels resolve `IBufferManagerFactory` from dependency injection. The default factory selects `FastBufferManager` in Release builds, `CookieBufferManager` in Debug builds, and `TracingBufferManager` when the stack is compiled with `TRACK_MEMORY`.
122+
123+
Register options before `AddOpcUa()` to select an implementation explicitly or apply a process-wide outstanding-buffer budget:
124+
125+
```csharp
126+
services.AddSingleton(new BufferManagerFactoryOptions
127+
{
128+
ImplementationKind = BufferManagerImplementationKind.Fast,
129+
MaxOutstandingBytesPerProcess = 256L * 1024 * 1024
130+
});
131+
132+
services.AddOpcUa()
133+
.AddOpcTcpTransport()
134+
.AddHttpsTransport();
135+
```
136+
137+
When `MaxOutstandingBytesPerProcess` is positive, the singleton factory wraps every manager it creates with `LimitingBufferManager` and shares one `BufferManagerMemoryLimiter` across them. A synchronous rent blocks without holding a manager lock until another buffer is returned. A single rent whose conservative expected size exceeds the budget fails immediately instead of waiting forever.
138+
139+
Applications can replace the complete policy by registering an `IBufferManagerFactory` before `AddOpcUa()`:
140+
141+
```csharp
142+
services.AddSingleton<IBufferManagerFactory, MyBufferManagerFactory>();
143+
services.AddOpcUa();
144+
```
145+
146+
The existing `new BufferManager(name, maxBufferSize, telemetry)` path remains available for direct creation. `BufferManager` is a compatibility facade over `IBufferManager`; `FastBufferManager`, `CookieBufferManager`, `TracingBufferManager`, and `LimitingBufferManager` can also be created directly for advanced scenarios.
147+
117148
Discovery servers expose the same transport and reverse-connect shortcuts as
118149
the regular server builder:
119150

Stack/Opc.Ua.Bindings.Https/DependencyInjection/OpcUaHttpsBuilderExtensions.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,10 +217,14 @@ private static IOpcUaBuilder AddWssBindings(IOpcUaBuilder builder)
217217

218218
private static void RegisterWssFactories(ITransportBindingRegistry registry, IServiceProvider provider)
219219
{
220+
IBufferManagerFactory bufferManagerFactory =
221+
provider.GetRequiredService<IBufferManagerFactory>();
220222
RegisterHttpsListener(registry, provider, new WssTransportListenerFactory());
221223
RegisterHttpsListener(registry, provider, new OpcWssTransportListenerFactory());
222-
registry.RegisterChannelFactory(new WssTransportChannelFactory());
223-
registry.RegisterChannelFactory(new OpcWssTransportChannelFactory());
224+
registry.RegisterChannelFactory(
225+
new WssTransportChannelFactory(bufferManagerFactory));
226+
registry.RegisterChannelFactory(
227+
new OpcWssTransportChannelFactory(bufferManagerFactory));
224228
registry.RegisterChannelFactory(new WssJsonTransportChannelFactory());
225229
}
226230

@@ -229,6 +233,9 @@ private static void RegisterHttpsListener(
229233
IServiceProvider provider,
230234
HttpsServiceHost factory)
231235
{
236+
factory.BufferManagerFactory =
237+
provider.GetService<IBufferManagerFactory>() ??
238+
DefaultBufferManagerFactory.Instance;
232239
foreach (IHttpsListenerStartupContributor contributor in
233240
provider.GetServices<IHttpsListenerStartupContributor>())
234241
{

Stack/Opc.Ua.Bindings.Https/DependencyInjection/OpcUaKestrelTcpBuilderExtensions.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,14 @@ public static IOpcUaBuilder AddKestrelOpcTcpTransport(this IOpcUaBuilder builder
6363
}
6464

6565
builder.Services.AddTransportBindingRegistry();
66-
builder.Services.AddSingleton<ITransportBindingConfigurator>(
67-
new TransportBindingConfigurator(registry => registry.RegisterListenerFactory(new KestrelTcpTransportListenerFactory())));
66+
builder.Services.AddSingleton<ITransportBindingConfigurator>(provider =>
67+
{
68+
IBufferManagerFactory bufferManagerFactory =
69+
provider.GetRequiredService<IBufferManagerFactory>();
70+
return new TransportBindingConfigurator(
71+
registry => registry.RegisterListenerFactory(
72+
new KestrelTcpTransportListenerFactory(bufferManagerFactory)));
73+
});
6874
return builder;
6975
}
7076
}

Stack/Opc.Ua.Bindings.Https/Https/HttpsServiceHost.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ public abstract class HttpsServiceHost : ITransportListenerFactory
5959
public IList<IHttpsListenerStartupContributor> StartupContributors { get; }
6060
= [];
6161

62+
/// <summary>
63+
/// Gets or sets the factory used to create listener buffer managers.
64+
/// </summary>
65+
internal IBufferManagerFactory BufferManagerFactory { get; set; } =
66+
DefaultBufferManagerFactory.Instance;
67+
6268
/// <summary>
6369
/// The OPC UA <c>TransportProfileUri</c> reported on the
6470
/// <see cref="EndpointDescription.TransportProfileUri"/> emitted for

Stack/Opc.Ua.Bindings.Https/Https/HttpsTransportListener.cs

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,11 @@ public class HttpsTransportListenerFactory : HttpsServiceHost
7676
/// <returns>The transport listener.</returns>
7777
public override ITransportListener Create(ITelemetryContext telemetry)
7878
{
79-
return new HttpsTransportListener(Utils.UriSchemeHttps, telemetry, [.. StartupContributors]);
79+
return new HttpsTransportListener(
80+
Utils.UriSchemeHttps,
81+
telemetry,
82+
[.. StartupContributors],
83+
BufferManagerFactory);
8084
}
8185
}
8286

@@ -103,7 +107,11 @@ public class OpcHttpsTransportListenerFactory : HttpsServiceHost
103107
/// <returns>The transport listener.</returns>
104108
public override ITransportListener Create(ITelemetryContext telemetry)
105109
{
106-
return new HttpsTransportListener(Utils.UriSchemeOpcHttps, telemetry, [.. StartupContributors]);
110+
return new HttpsTransportListener(
111+
Utils.UriSchemeOpcHttps,
112+
telemetry,
113+
[.. StartupContributors],
114+
BufferManagerFactory);
107115
}
108116
}
109117

@@ -129,7 +137,11 @@ public class WssTransportListenerFactory : HttpsServiceHost
129137
/// <inheritdoc/>
130138
public override ITransportListener Create(ITelemetryContext telemetry)
131139
{
132-
return new HttpsTransportListener(Utils.UriSchemeWss, telemetry, [.. StartupContributors]);
140+
return new HttpsTransportListener(
141+
Utils.UriSchemeWss,
142+
telemetry,
143+
[.. StartupContributors],
144+
BufferManagerFactory);
133145
}
134146
}
135147

@@ -155,7 +167,11 @@ public class OpcWssTransportListenerFactory : HttpsServiceHost
155167
/// <inheritdoc/>
156168
public override ITransportListener Create(ITelemetryContext telemetry)
157169
{
158-
return new HttpsTransportListener(Utils.UriSchemeOpcWss, telemetry, [.. StartupContributors]);
170+
return new HttpsTransportListener(
171+
Utils.UriSchemeOpcWss,
172+
telemetry,
173+
[.. StartupContributors],
174+
BufferManagerFactory);
159175
}
160176
}
161177

@@ -321,10 +337,12 @@ public class HttpsTransportListener : ITransportListener, ITransportListenerCert
321337
/// Initializes a new instance of the <see cref="HttpsTransportListener"/> class.
322338
/// </summary>
323339
public HttpsTransportListener(string uriScheme, ITelemetryContext telemetry)
340+
: this(
341+
uriScheme,
342+
telemetry,
343+
startupContributors: [],
344+
DefaultBufferManagerFactory.Instance)
324345
{
325-
UriScheme = uriScheme;
326-
m_telemetry = telemetry;
327-
m_logger = telemetry.CreateLogger<HttpsTransportListener>();
328346
}
329347

330348
/// <summary>
@@ -338,9 +356,29 @@ internal HttpsTransportListener(
338356
string uriScheme,
339357
ITelemetryContext telemetry,
340358
IReadOnlyList<IHttpsListenerStartupContributor> startupContributors)
341-
: this(uriScheme, telemetry)
359+
: this(
360+
uriScheme,
361+
telemetry,
362+
startupContributors,
363+
DefaultBufferManagerFactory.Instance)
342364
{
365+
}
366+
367+
/// <summary>
368+
/// Initializes a listener with startup contributors and a buffer-manager factory.
369+
/// </summary>
370+
internal HttpsTransportListener(
371+
string uriScheme,
372+
ITelemetryContext telemetry,
373+
IReadOnlyList<IHttpsListenerStartupContributor> startupContributors,
374+
IBufferManagerFactory bufferManagerFactory)
375+
{
376+
UriScheme = uriScheme;
377+
m_telemetry = telemetry;
378+
m_logger = telemetry.CreateLogger<HttpsTransportListener>();
343379
StartupContributors = startupContributors;
380+
m_bufferManagerFactory = bufferManagerFactory ??
381+
throw new ArgumentNullException(nameof(bufferManagerFactory));
344382
}
345383

346384
/// <summary>
@@ -550,9 +588,10 @@ public async ValueTask OpenAsync(
550588

551589
// buffer manager used by the WSS path to rent send / receive chunks.
552590
m_bufferManager = new BufferManager(
553-
"HttpsListener",
554-
m_quotas.MaxBufferSize,
555-
m_telemetry);
591+
m_bufferManagerFactory.Create(
592+
"HttpsListener",
593+
m_quotas.MaxBufferSize,
594+
m_telemetry));
556595

557596
// start the listener
558597
await StartAsync(ct).ConfigureAwait(false);
@@ -1786,7 +1825,8 @@ private async Task AcceptWebSocketJsonAsync(HttpContext context)
17861825
{
17871826
receiveBuffer ??= m_bufferManager.TakeBuffer(
17881827
m_quotas.MaxBufferSize,
1789-
nameof(AcceptWebSocketJsonAsync));
1828+
nameof(AcceptWebSocketJsonAsync),
1829+
ct);
17901830

17911831
int totalRead = 0;
17921832
bool completed;
@@ -1998,7 +2038,8 @@ private async Task AcceptWebSocketOpenApiAsync(
19982038
{
19992039
receiveBuffer ??= m_bufferManager.TakeBuffer(
20002040
m_quotas.MaxBufferSize,
2001-
nameof(AcceptWebSocketOpenApiAsync));
2041+
nameof(AcceptWebSocketOpenApiAsync),
2042+
ct);
20022043

20032044
int totalRead = 0;
20042045
bool completed;
@@ -2405,6 +2446,7 @@ private bool ValidateClientCertificate(
24052446
private List<EndpointDescription> m_descriptions = null!;
24062447
private ChannelQuotas m_quotas = null!;
24072448
private BufferManager m_bufferManager = null!;
2449+
private readonly IBufferManagerFactory m_bufferManagerFactory;
24082450
private ITransportListenerCallback? m_callback;
24092451
#if NET8_0_OR_GREATER
24102452
private IHost? m_host;

Stack/Opc.Ua.Bindings.Https/Https/WebSocketByteTransport.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,10 @@ public async ValueTask SendChunkAsync(BufferCollection buffers, CancellationToke
120120
// WebSocket does not support a vectored send out of the box; concat the
121121
// gathered segments into a single contiguous chunk for the frame.
122122
int totalSize = buffers.TotalSize;
123-
byte[] frame = m_bufferManager.TakeBuffer(totalSize, nameof(SendChunkAsync));
123+
byte[] frame = m_bufferManager.TakeBuffer(
124+
totalSize,
125+
nameof(SendChunkAsync),
126+
ct);
124127
try
125128
{
126129
int offset = 0;
@@ -167,7 +170,10 @@ await socket
167170
public async ValueTask<ArraySegment<byte>> ReceiveChunkAsync(CancellationToken ct)
168171
{
169172
WebSocket socket = RequireOpenSocket();
170-
byte[] buffer = m_bufferManager.TakeBuffer(m_receiveBufferSize, nameof(ReceiveChunkAsync));
173+
byte[] buffer = m_bufferManager.TakeBuffer(
174+
m_receiveBufferSize,
175+
nameof(ReceiveChunkAsync),
176+
ct);
171177
int totalRead = 0;
172178
try
173179
{

0 commit comments

Comments
 (0)