Make the message-extension seam internal to Opc.Ua.Core

The seam is inline-only. Checking every one of its symbols against
Opc.Ua.Bindings.Quic gives zero references to ISecureChannelMessageHost,
ISecureChannelMessageExtension, SequenceNumberBudget, the send gate and
UaSCSecureChannelRegistry alike - a QUIC data channel rides its own stream and
never becomes a UASC chunk, so none of it applies. The seam therefore exists to
keep the engine out of Core, not to invite other implementers: there is exactly
one, and no prospect of a second.

Publishing it would commit Core to an abstraction with a single implementer for
ever. Core already grants InternalsVisibleTo to its sibling first-party
assemblies - Opc.Ua.Client, Opc.Ua.Bindings.Quic, Opc.Ua.Bindings.Https,
Opc.Ua.Core.Diagnostics - and Opc.Ua.Core.Channels had simply never been added
to that list, which is the only reason the seam had to be public. Adding it
lets the following become internal with no code moving at all:

* ISecureChannelMessageHost and ISecureChannelMessageExtension
* SequenceNumberBudget, and UaSCUaBinaryChannel.SequenceBudget
* RegisterMessageExtension and TryGetMessageExtension
* DataChannelExtension and TryGetDataChannelExtension, which are the inline
  adapter rather than API - a consumer reaches the channels through
  EnableDataChannels and GetDataChannels, which return the engine

UaSCSecureChannelRegistry stays public deliberately. An application writing its
own IServerDataChannelTransport for inline framing needs it to resolve the
channel behind a request, which samples/ConsoleDataChannelStreaming
demonstrates; making it internal would break a real extension point to tidy an
API surface.

The public surface this PR adds to Opc.Ua.Core is now UaSCSecureChannelRegistry
and IUaSCSecureChannelBoundTransport, the latter being a transport extension
point alongside the existing public IUaSCByteTransport.

This captures the main benefit of folding the engine back into Core - not
owing compatibility on a single-implementer abstraction - without moving 4866
lines of experimental engine into Core on every TFM from net472 upward, and
without touching the send gate or the sequence budget, both of which are
inherent to inline framing wherever its code lives.

UA.slnx builds pinned to net10.0, 316 data channel tests pass, and
Opc.Ua.Server.Tests is unchanged at 4072.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5d026b47-265d-4c97-8715-26e207dfa03c
This commit is contained in:
Marc
2026-08-12 11:17:54 +02:00
co-authored by Copilot
parent 71fba920ec
commit 18a30770df
7 changed files with 31 additions and 13 deletions
+16 -5
View File
@@ -56,14 +56,25 @@ sequence-checks every chunk before the engine sees it. Core itself carries no da
vocabulary — `ISecureChannelMessageExtension` and `ISecureChannelMessageHost` name only "a
MessageType that is neither a Service call nor part of establishing the SecureChannel".
Both are `internal`, reached through the `InternalsVisibleTo` that Core already grants its
sibling first-party assemblies (`Opc.Ua.Client`, `Opc.Ua.Bindings.Quic`,
`Opc.Ua.Bindings.Https`). The seam exists to keep the engine out of Core, not to invite
other implementers — there is exactly one, and the `opc.quic` binding does not use any of
it, because a QUIC data channel rides its own stream and never becomes a UASC chunk.
Publishing it would commit Core to an abstraction with one implementer for ever.
Two things stay in Core because they are the SecureChannel's own concerns rather than the
feature's: `SequenceNumberBudget`, which tracks the sequence space every MessageType draws
on, and `UaSCSecureChannelRegistry`, which maps a SecureChannel identifier to the channel
that owns it. Because the budget is Core's, the channel claims the SequenceNumber itself
while it secures the chunk and refuses the send with `Bad_SecureChannelTokenUnknown` when
the space under the current token is exhausted — an extension stalls rather than reuse a
number, and never has to reason about the serialization to do so. The `opc.quic` transport
remains its own package, `OPCFoundation.NetStandard.Opc.Ua.Bindings.Quic`.
that owns it. The budget is internal for the same reason as the seam; the registry is
public because an application writing its own `IServerDataChannelTransport` for inline
framing needs it to resolve the channel behind a request, which
`samples/ConsoleDataChannelStreaming` demonstrates. Because the budget is Core's, the
channel claims the SequenceNumber itself while it secures the chunk and refuses the send
with `Bad_SecureChannelTokenUnknown` when the space under the current token is exhausted —
an extension stalls rather than reuse a number, and never has to reason about the
serialization to do so. The `opc.quic` transport remains its own package,
`OPCFoundation.NetStandard.Opc.Ua.Bindings.Quic`.
## Server side
@@ -42,7 +42,7 @@ namespace Opc.Ua.Bindings
/// This owns the STR MessageType on the channel and is the only thing that
/// knows the frame format; the channel itself sees an opaque body.
/// </remarks>
public sealed class DataChannelExtension : ISecureChannelMessageExtension, IDataChannelTransport
internal sealed class DataChannelExtension : ISecureChannelMessageExtension, IDataChannelTransport
{
/// <summary>
/// Attaches data channels to a SecureChannel.
@@ -111,10 +111,16 @@ namespace Opc.Ua.Bindings
/// Returns the extension that owns the STR MessageType on a
/// SecureChannel, if one is registered.
/// </summary>
/// <remarks>
/// The extension is an implementation detail of the inline framing:
/// a consumer reaches the channels through
/// <see cref="GetDataChannels"/>, which returns the engine rather
/// than the adapter that drives it.
/// </remarks>
/// <param name="channel">The SecureChannel.</param>
/// <param name="extension">The extension.</param>
/// <exception cref="ArgumentNullException"><paramref name="channel"/> is <c>null</c>.</exception>
public static bool TryGetDataChannelExtension(
internal static bool TryGetDataChannelExtension(
this UaSCUaBinaryChannel channel,
out DataChannelExtension? extension)
{
+1
View File
@@ -23,6 +23,7 @@
<InternalsVisibleTo Include="Opc.Ua.Bindings.Https" />
<InternalsVisibleTo Include="Opc.Ua.Bindings.Quic" />
<InternalsVisibleTo Include="Opc.Ua.Client" />
<InternalsVisibleTo Include="Opc.Ua.Core.Channels" />
<InternalsVisibleTo Include="Opc.Ua.Core.Diagnostics" />
<InternalsVisibleTo Include="Opc.Ua.Core.Diagnostics.Tests" />
</ItemGroup>
@@ -43,7 +43,7 @@ namespace Opc.Ua.Bindings
/// from the channel's single SequenceNumber space. This is the surface that
/// makes that possible without publishing the channel's internals.
/// </remarks>
public interface ISecureChannelMessageHost
internal interface ISecureChannelMessageHost
{
/// <summary>
/// The largest chunk the peer accepts.
@@ -114,7 +114,7 @@ namespace Opc.Ua.Bindings
/// faults, which is what OPC 10000-6 §6.7.2.2 requires of a receiver that
/// does not implement it.
/// </remarks>
public interface ISecureChannelMessageExtension
internal interface ISecureChannelMessageExtension
{
/// <summary>
/// The MessageType this extension owns, without the chunk type.
@@ -64,7 +64,7 @@ namespace Opc.Ua.Bindings
/// than emitting a chunk that would reuse a value.
/// </para>
/// </remarks>
public sealed class SequenceNumberBudget
internal sealed class SequenceNumberBudget
{
/// <summary>
/// Creates a budget.
@@ -60,7 +60,7 @@ namespace Opc.Ua.Bindings
/// under the current SecurityToken. Every MessageType the channel
/// carries draws on the same space.
/// </summary>
public SequenceNumberBudget SequenceBudget
internal SequenceNumberBudget SequenceBudget
{
get
{
@@ -89,7 +89,7 @@ namespace Opc.Ua.Bindings
/// <returns>The registered extension, which is <paramref name="extension"/>
/// unless another was registered first.</returns>
/// <exception cref="ArgumentNullException"><paramref name="extension"/> is <c>null</c>.</exception>
public ISecureChannelMessageExtension RegisterMessageExtension(
internal ISecureChannelMessageExtension RegisterMessageExtension(
ISecureChannelMessageExtension extension)
{
if (extension == null)
@@ -105,7 +105,7 @@ namespace Opc.Ua.Bindings
/// </summary>
/// <param name="messageType">The MessageType, without the chunk type.</param>
/// <param name="extension">The extension.</param>
public bool TryGetMessageExtension(
internal bool TryGetMessageExtension(
uint messageType,
out ISecureChannelMessageExtension? extension)
{