diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index 0509e60..9878d51 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs @@ -154,23 +154,57 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable } /// - /// Writes the union of and - /// to the OPC UA standard - /// Server.ServerArray property (NodeId i=2254). Clients in a warm-redundancy - /// deployment discover the partner endpoint by reading this property. + /// Publishes via the OPC UA + /// standard Server.ServerArray property (NodeId i=2254) so warm-redundancy clients + /// can discover the partner endpoint. + /// + /// The wire-served value of Server.ServerArray comes from + /// (an ) via the + /// SDK's OnReadServerArray callback — writes to + /// ServerObject.ServerArray.Value are NOT what clients read. The SDK auto-populates + /// slot 0 with the local ApplicationUri on ApplicationInstance.Start; we + /// append the configured peers at slots 1, 2, … here. + /// + /// The address-space property is also mirrored for in-process readers (the unit-test + /// observation seam) and as a defensive belt-and-braces measure. /// private void PopulateServerArray() { - var serverObject = _server?.CurrentInstance?.ServerObject; - if (serverObject is null) return; + var internalData = _server?.CurrentInstance; + if (internalData is null) return; + + // Wire path: append peers to IServerInternal.ServerUris — this is what + // OnReadServerArray serves to remote clients reading VariableIds.Server_ServerArray. + var serverUris = internalData.ServerUris; + var existing = new HashSet(StringComparer.Ordinal); + for (uint i = 0; i < (uint)serverUris.Count; i++) + { + var existingUri = serverUris.GetString(i); + if (existingUri is not null) existing.Add(existingUri); + } - var uris = new List { _options.ApplicationUri }; foreach (var peer in _options.PeerApplicationUris) { - if (!string.IsNullOrWhiteSpace(peer) && !uris.Contains(peer)) - uris.Add(peer); + if (string.IsNullOrWhiteSpace(peer)) continue; + if (existing.Contains(peer)) continue; + serverUris.Append(peer); + existing.Add(peer); + } + + // In-process mirror: ServerObject.ServerArray.Value is consulted by some tests and + // tooling that read the SDK's address-space model directly rather than going through + // a session. Harmless on the wire (the SDK ignores it) but useful in-VM. + var serverObject = internalData.ServerObject; + if (serverObject is not null) + { + var uris = new List { _options.ApplicationUri }; + foreach (var peer in _options.PeerApplicationUris) + { + if (!string.IsNullOrWhiteSpace(peer) && !uris.Contains(peer)) + uris.Add(peer); + } + serverObject.ServerArray.Value = uris.ToArray(); } - serverObject.ServerArray.Value = uris.ToArray(); } private void OnImpersonateUser(Session session, ImpersonateEventArgs args) =>