diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/typetree/ClientBrowseUtilsTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/typetree/ClientBrowseUtilsTest.java new file mode 100644 index 0000000000..bac23e5b28 --- /dev/null +++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/typetree/ClientBrowseUtilsTest.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.client.typetree; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import org.eclipse.milo.opcua.sdk.client.OpcUaClient; +import org.eclipse.milo.opcua.stack.core.UaException; +import org.eclipse.milo.opcua.stack.core.types.builtin.ByteString; +import org.eclipse.milo.opcua.stack.core.types.structured.BrowseNextResponse; +import org.eclipse.milo.opcua.stack.core.types.structured.BrowseResult; +import org.junit.jupiter.api.Test; + +class ClientBrowseUtilsTest { + + @Test + void releasesContinuationPointWhenBrowseNextLimitIsReached() throws UaException { + var client = mock(OpcUaClient.class); + var response = mock(BrowseNextResponse.class); + var result = mock(BrowseResult.class); + var continuationPoint = ByteString.of(new byte[] {1, 2, 3, 4}); + + when(client.browseNext(false, List.of(continuationPoint))).thenReturn(response); + when(response.getResults()).thenReturn(new BrowseResult[] {result}); + when(result.getContinuationPoint()).thenReturn(continuationPoint); + + assertThrows( + UaException.class, () -> ClientBrowseUtils.maybeBrowseNext(client, continuationPoint)); + + verify(client, times(1000)).browseNext(false, List.of(continuationPoint)); + verify(client).browseNext(true, List.of(continuationPoint)); + } +} diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/typetree/DataTypeTreeFactoryTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/typetree/DataTypeTreeFactoryTest.java index 64aa2cce68..65cbc4316e 100644 --- a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/typetree/DataTypeTreeFactoryTest.java +++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/typetree/DataTypeTreeFactoryTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 the Eclipse Milo Authors + * Copyright (c) 2026 the Eclipse Milo Authors * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -17,6 +17,8 @@ import org.eclipse.milo.opcua.sdk.core.typetree.DataTypeTree; import org.eclipse.milo.opcua.sdk.test.AbstractClientServerTest; import org.eclipse.milo.opcua.stack.core.NodeIds; +import org.eclipse.milo.opcua.stack.core.encoding.EncodingContext; +import org.eclipse.milo.opcua.stack.core.types.DataTypeManager; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; @@ -62,6 +64,21 @@ void setDataTypeTreeFactoryResetsCache() throws Exception { assertInstanceOf(LazyClientDataTypeTree.class, tree2); } + @Test + void readDataTypeTreeResetsDerivedCaches() throws Exception { + DataTypeTree treeBefore = client.getDataTypeTree(); + DataTypeManager managerBefore = client.getDynamicDataTypeManager(); + EncodingContext contextBefore = client.getDynamicEncodingContext(); + + DataTypeTree treeAfter = client.readDataTypeTree(); + + // The dynamic DataTypeManager and EncodingContext hold references to the tree they were + // created against, so refreshing the tree must rebuild them too. + assertNotSame(treeBefore, treeAfter); + assertNotSame(managerBefore, client.getDynamicDataTypeManager()); + assertNotSame(contextBefore, client.getDynamicEncodingContext()); + } + @Test void customFactoryIsUsed() throws Exception { var customTree = new LazyClientDataTypeTree(client); diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/typetree/LazyClientDataTypeTreeTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/typetree/LazyClientDataTypeTreeTest.java index ad31b00fd1..390b810e2f 100644 --- a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/typetree/LazyClientDataTypeTreeTest.java +++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/typetree/LazyClientDataTypeTreeTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 the Eclipse Milo Authors + * Copyright (c) 2026 the Eclipse Milo Authors * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -14,6 +14,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.concurrent.atomic.AtomicInteger; @@ -36,13 +37,17 @@ public class LazyClientDataTypeTreeTest extends AbstractDataTypeTreeTest { @Override protected DataTypeTree getDataTypeTree() { + return newLazyTree(); + } + + private LazyClientDataTypeTree newLazyTree() { return new LazyClientDataTypeTree(client); } @Test void initiallyOnlyContainsBaseDataType() { // Create a fresh tree to test the initial state - var freshTree = new LazyClientDataTypeTree(client); + var freshTree = newLazyTree(); // BaseDataType should be resolved (it's the root) assertTrue(freshTree.isResolved(NodeIds.BaseDataType)); @@ -55,7 +60,7 @@ void initiallyOnlyContainsBaseDataType() { @Test void resolvesTypeOnDemand() { - var freshTree = new LazyClientDataTypeTree(client); + var freshTree = newLazyTree(); // Int32 not resolved initially assertFalse(freshTree.isResolved(NodeIds.Int32)); @@ -76,7 +81,7 @@ void resolvesTypeOnDemand() { @Test void resolvesStructuredTypes() { - var freshTree = new LazyClientDataTypeTree(client); + var freshTree = newLazyTree(); // Query a structured type DataType xvType = freshTree.getDataType(NodeIds.XVType); @@ -96,7 +101,7 @@ void resolvesStructuredTypes() { @Test void isSubtypeOfWorksWithLazyResolution() { - var freshTree = new LazyClientDataTypeTree(client); + var freshTree = newLazyTree(); // Neither Int32 nor Integer are resolved yet assertFalse(freshTree.isResolved(NodeIds.Int32)); @@ -113,7 +118,7 @@ void isSubtypeOfWorksWithLazyResolution() { @Test void containsTypeTriggersResolution() { - var freshTree = new LazyClientDataTypeTree(client); + var freshTree = newLazyTree(); // Double not resolved initially assertFalse(freshTree.isResolved(NodeIds.Double)); @@ -127,7 +132,7 @@ void containsTypeTriggersResolution() { @Test void cachesResolvedTypes() { - var freshTree = new LazyClientDataTypeTree(client); + var freshTree = newLazyTree(); // First query - triggers resolution DataType first = freshTree.getDataType(NodeIds.Int32); @@ -144,7 +149,7 @@ void cachesResolvedTypes() { @Test void lazyTreeMatchesEagerTreeForResolvedTypes() throws UaException { DataTypeTree eagerTree = DataTypeTreeBuilder.build(client); - var lazyTestTree = new LazyClientDataTypeTree(client); + var lazyTestTree = newLazyTree(); // Test a variety of types NodeId[] typesToTest = { @@ -188,9 +193,8 @@ void lazyTreeMatchesEagerTreeForResolvedTypes() throws UaException { } @Test - void diagnosticTestForStructure() throws Exception { - // Test that Structure can be resolved - var freshTree = new LazyClientDataTypeTree(client); + void getTypeTriggersLazyResolution() { + var freshTree = newLazyTree(); // BaseDataType should be the only resolved type initially assertTrue(freshTree.isResolved(NodeIds.BaseDataType), "BaseDataType should be pre-loaded"); @@ -205,26 +209,29 @@ void diagnosticTestForStructure() throws Exception { @Test void clearFailedResolutionsAllowsRetry() { - var freshTree = new LazyClientDataTypeTree(client); + var freshTree = newLazyTree(); - // Try to resolve a non-existent type + // Resolving a non-existent type fails and leaves the type unresolved NodeId fakeTypeId = new NodeId(999, "FakeDataType"); - DataType result = freshTree.getDataType(fakeTypeId); + assertNull(freshTree.getDataType(fakeTypeId)); + assertFalse(freshTree.isResolved(fakeTypeId)); - // Should return null - assertTrue(result == null || !freshTree.isResolved(fakeTypeId)); + // Resolve a real type as a control for the clear below + assertNotNull(freshTree.getDataType(NodeIds.Int32)); - // Clear failed resolutions freshTree.clearFailedResolutions(); - // Now it can be attempted again (will still fail, but the point is it's retried) - result = freshTree.getDataType(fakeTypeId); - assertTrue(result == null || !freshTree.isResolved(fakeTypeId)); + // Clearing failed resolutions retains resolved types + assertTrue(freshTree.isResolved(NodeIds.Int32)); + + // The failed type can be attempted again (it fails again against this server) + assertNull(freshTree.getDataType(fakeTypeId)); + assertFalse(freshTree.isResolved(fakeTypeId)); } @Test void getRootReturnsSnapshot() { - var freshTree = new LazyClientDataTypeTree(client); + var freshTree = newLazyTree(); // Initially only BaseDataType is in the tree Tree snapshot1 = freshTree.getRoot(); @@ -253,7 +260,7 @@ void getRootReturnsSnapshot() { @Test void getRootSnapshotIsTraversable() { - var freshTree = new LazyClientDataTypeTree(client); + var freshTree = newLazyTree(); // Resolve a few types to build up the tree freshTree.getDataType(NodeIds.Int32); diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/typetree/SeededLazyClientDataTypeTreeTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/typetree/SeededLazyClientDataTypeTreeTest.java index e57a9fad72..d7d068b789 100644 --- a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/typetree/SeededLazyClientDataTypeTreeTest.java +++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/typetree/SeededLazyClientDataTypeTreeTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 the Eclipse Milo Authors + * Copyright (c) 2026 the Eclipse Milo Authors * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -13,6 +13,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.concurrent.atomic.AtomicInteger; @@ -38,13 +39,16 @@ public class SeededLazyClientDataTypeTreeTest extends AbstractDataTypeTreeTest { @Override protected DataTypeTree getDataTypeTree() { + return newSeededTree(); + } + + private LazyClientDataTypeTree newSeededTree() { return new LazyClientDataTypeTree(client, LazyClientDataTypeTreeSeed.createSeedTree()); } @Test void seededTypesAreImmediatelyResolved() { - var seededTree = - new LazyClientDataTypeTree(client, LazyClientDataTypeTreeSeed.createSeedTree()); + var seededTree = newSeededTree(); // All types from the seed should be resolved immediately assertTrue(seededTree.isResolved(NodeIds.BaseDataType)); @@ -62,8 +66,7 @@ void seededTypesAreImmediatelyResolved() { @Test void primitiveTypesAvailableWithoutResolution() { - var seededTree = - new LazyClientDataTypeTree(client, LazyClientDataTypeTreeSeed.createSeedTree()); + var seededTree = newSeededTree(); // Query primitive types - should return immediately without server browsing DataType int32Type = seededTree.getDataType(NodeIds.Int32); @@ -81,8 +84,7 @@ void primitiveTypesAvailableWithoutResolution() { @Test void subTypesAreAvailable() { - var seededTree = - new LazyClientDataTypeTree(client, LazyClientDataTypeTreeSeed.createSeedTree()); + var seededTree = newSeededTree(); // String subtypes from the seed assertTrue(seededTree.isResolved(NodeIds.NumericRange)); @@ -100,8 +102,7 @@ void subTypesAreAvailable() { @Test void isSubtypeOfWorksForSeededTypes() { - var seededTree = - new LazyClientDataTypeTree(client, LazyClientDataTypeTreeSeed.createSeedTree()); + var seededTree = newSeededTree(); // Int32 -> Integer -> Number -> BaseDataType assertTrue(seededTree.isSubtypeOf(NodeIds.Int32, NodeIds.Integer)); @@ -121,8 +122,7 @@ void isSubtypeOfWorksForSeededTypes() { @Test void enumerationSubtypesAreSeeded() { - var seededTree = - new LazyClientDataTypeTree(client, LazyClientDataTypeTreeSeed.createSeedTree()); + var seededTree = newSeededTree(); // Enumeration and its subtypes are in the seed assertTrue(seededTree.isResolved(NodeIds.Enumeration)); @@ -133,8 +133,7 @@ void enumerationSubtypesAreSeeded() { @Test void seededEnumerationSubtypesHaveDefinitions() { - var seededTree = - new LazyClientDataTypeTree(client, LazyClientDataTypeTreeSeed.createSeedTree()); + var seededTree = newSeededTree(); // NodeClass is a seeded enum subtype with an EnumDefinition DataType nodeClassType = seededTree.getDataType(NodeIds.NodeClass); @@ -152,8 +151,7 @@ void seededEnumerationSubtypesHaveDefinitions() { @Test void structureSubtypesAreSeeded() { - var seededTree = - new LazyClientDataTypeTree(client, LazyClientDataTypeTreeSeed.createSeedTree()); + var seededTree = newSeededTree(); // Structure and its subtypes are in the seed assertTrue(seededTree.isResolved(NodeIds.Structure)); @@ -164,8 +162,7 @@ void structureSubtypesAreSeeded() { @Test void seededStructureSubtypesHaveEncodingIdsAndDefinitions() { - var seededTree = - new LazyClientDataTypeTree(client, LazyClientDataTypeTreeSeed.createSeedTree()); + var seededTree = newSeededTree(); // XVType is a seeded structure subtype with encoding IDs and definition DataType xvType = seededTree.getDataType(NodeIds.XVType); @@ -186,8 +183,7 @@ void seededStructureSubtypesHaveEncodingIdsAndDefinitions() { @Test void seededTreeMatchesEagerTreeForCommonTypes() throws UaException { DataTypeTree eagerTree = DataTypeTreeBuilder.build(client); - var seededTree = - new LazyClientDataTypeTree(client, LazyClientDataTypeTreeSeed.createSeedTree()); + var seededTree = newSeededTree(); // Test types that are in the seed NodeId[] seededTypes = { @@ -222,8 +218,7 @@ void seededTreeMatchesEagerTreeForCommonTypes() throws UaException { @Test void getRootContainsAllSeededTypes() { - var seededTree = - new LazyClientDataTypeTree(client, LazyClientDataTypeTreeSeed.createSeedTree()); + var seededTree = newSeededTree(); Tree root = seededTree.getRoot(); var count = new AtomicInteger(0); @@ -235,28 +230,26 @@ void getRootContainsAllSeededTypes() { @Test void clearFailedResolutionsAllowsRetry() { - var seededTree = - new LazyClientDataTypeTree(client, LazyClientDataTypeTreeSeed.createSeedTree()); + var seededTree = newSeededTree(); - // Try to resolve a non-existent type + // Resolving a non-existent type fails and leaves the type unresolved NodeId fakeTypeId = new NodeId(999, "FakeDataType"); - DataType result = seededTree.getDataType(fakeTypeId); - - // Should return null - assertTrue(result == null || !seededTree.isResolved(fakeTypeId)); + assertNull(seededTree.getDataType(fakeTypeId)); + assertFalse(seededTree.isResolved(fakeTypeId)); - // Clear failed resolutions seededTree.clearFailedResolutions(); - // Now it can be attempted again - result = seededTree.getDataType(fakeTypeId); - assertTrue(result == null || !seededTree.isResolved(fakeTypeId)); + // Clearing failed resolutions retains resolved (seeded) types + assertTrue(seededTree.isResolved(NodeIds.Int32)); + + // The failed type can be attempted again (it fails again against this server) + assertNull(seededTree.getDataType(fakeTypeId)); + assertFalse(seededTree.isResolved(fakeTypeId)); } @Test void containsTypeReturnsTrueForSeededTypes() { - var seededTree = - new LazyClientDataTypeTree(client, LazyClientDataTypeTreeSeed.createSeedTree()); + var seededTree = newSeededTree(); // These should return true immediately without triggering resolution assertTrue(seededTree.containsType(NodeIds.Int32)); @@ -268,8 +261,7 @@ void containsTypeReturnsTrueForSeededTypes() { @Test void getBuiltinTypeWorksForSeededTypes() { - var seededTree = - new LazyClientDataTypeTree(client, LazyClientDataTypeTreeSeed.createSeedTree()); + var seededTree = newSeededTree(); // Builtin type resolution should work for seeded types assertEquals(OpcUaDataType.Int32, seededTree.getBuiltinType(NodeIds.Int32)); @@ -280,8 +272,7 @@ void getBuiltinTypeWorksForSeededTypes() { @Test void isEnumTypeWorksForSeededTypes() { - var seededTree = - new LazyClientDataTypeTree(client, LazyClientDataTypeTreeSeed.createSeedTree()); + var seededTree = newSeededTree(); // Enumeration subtypes are seeded and isEnumType works immediately assertTrue(seededTree.isEnumType(NodeIds.NodeClass)); @@ -295,8 +286,7 @@ void isEnumTypeWorksForSeededTypes() { @Test void isStructTypeWorksForSeededTypes() { - var seededTree = - new LazyClientDataTypeTree(client, LazyClientDataTypeTreeSeed.createSeedTree()); + var seededTree = newSeededTree(); // Structure is in the seed assertTrue(seededTree.isResolved(NodeIds.Structure)); diff --git a/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/OpcUaClient.java b/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/OpcUaClient.java index 228a40d7fa..972d1a5ff8 100644 --- a/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/OpcUaClient.java +++ b/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/OpcUaClient.java @@ -486,11 +486,10 @@ public UInteger getRequestTimeout() { addSessionInitializer( (client, session) -> { // Reset before the Session is available so that the DataTypeTree and associated codecs - // are refreshed (eagerly or lazily, depending on configuration). + // are refreshed (eagerly or lazily, depending on configuration). Resetting the tree + // also resets the dynamic DataTypeManager and EncodingContext derived from it. resetDataTypeTree(); - resetDynamicDataTypeManager(); - resetDynamicEncodingContext(); return CompletableFuture.completedFuture(Unit.VALUE); }); @@ -924,16 +923,29 @@ public DataTypeTree getDataTypeTree() throws UaException { } } - /** Reset the cached {@link DataTypeTree}. */ + /** + * Reset the cached {@link DataTypeTree}. + * + *

The dynamic {@link DataTypeManager} and dynamic {@link EncodingContext} are derived from the + * tree (dynamic codecs hold a reference to the tree they were created against), so they are reset + * along with it and will be rebuilt against the new tree the next time they are accessed. + */ public void resetDataTypeTree() { dataTypeTree.reset(); + + resetDynamicDataTypeManager(); + resetDynamicEncodingContext(); } /** * Read the {@link DataTypeTree} from the server and update the local copy. * + *

The dynamic {@link DataTypeManager} and dynamic {@link EncodingContext} are reset along with + * the tree and will be rebuilt against the new tree the next time they are accessed. + * * @return the updated {@link DataTypeTree}. * @throws UaException if an error occurs while reading the DataTypes. + * @see #resetDataTypeTree() */ public DataTypeTree readDataTypeTree() throws UaException { resetDataTypeTree(); @@ -1140,8 +1152,9 @@ public RequestHeader newRequestHeader(NodeId authToken, UInteger requestTimeout) * resolves types on demand. This can be more efficient when only a subset of types is needed or * when the server doesn't support recursive forward browsing of the DataType hierarchy. * - *

This resets the client's cached {@link DataTypeTree}. It will be built or rebuilt the next - * time it is accessed. + *

This resets the client's cached {@link DataTypeTree}, along with the dynamic {@link + * DataTypeManager} and dynamic {@link EncodingContext} derived from it. They will be built or + * rebuilt the next time they are accessed. * * @param dataTypeTreeFactory the {@link DataTypeTreeFactory} to set. * @see #getDataTypeTree() diff --git a/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/ClientBrowseUtils.java b/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/ClientBrowseUtils.java index f0621f7c6c..6655a52f55 100644 --- a/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/ClientBrowseUtils.java +++ b/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/ClientBrowseUtils.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 the Eclipse Milo Authors + * Copyright (c) 2026 the Eclipse Milo Authors * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -10,22 +10,30 @@ package org.eclipse.milo.opcua.sdk.client.typetree; -import static java.util.Objects.requireNonNull; import static java.util.Objects.requireNonNullElse; +import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint; import static org.eclipse.milo.opcua.stack.core.util.Lists.partition; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Objects; import org.eclipse.milo.opcua.sdk.client.OpcUaClient; import org.eclipse.milo.opcua.sdk.client.OpcUaSession; import org.eclipse.milo.opcua.sdk.client.OperationLimits; +import org.eclipse.milo.opcua.sdk.core.typetree.DataType; +import org.eclipse.milo.opcua.stack.core.NamespaceTable; +import org.eclipse.milo.opcua.stack.core.NodeIds; import org.eclipse.milo.opcua.stack.core.StatusCodes; import org.eclipse.milo.opcua.stack.core.UaException; +import org.eclipse.milo.opcua.stack.core.types.DataTypeEncoding; import org.eclipse.milo.opcua.stack.core.types.builtin.ByteString; import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue; import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger; +import org.eclipse.milo.opcua.stack.core.types.enumerated.BrowseDirection; +import org.eclipse.milo.opcua.stack.core.types.enumerated.BrowseResultMask; +import org.eclipse.milo.opcua.stack.core.types.enumerated.NodeClass; import org.eclipse.milo.opcua.stack.core.types.enumerated.TimestampsToReturn; import org.eclipse.milo.opcua.stack.core.types.structured.BrowseDescription; import org.eclipse.milo.opcua.stack.core.types.structured.BrowseNextResponse; @@ -33,6 +41,7 @@ import org.eclipse.milo.opcua.stack.core.types.structured.ReadResponse; import org.eclipse.milo.opcua.stack.core.types.structured.ReadValueId; import org.eclipse.milo.opcua.stack.core.types.structured.ReferenceDescription; +import org.eclipse.milo.opcua.stack.core.types.structured.StructureDefinition; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -48,6 +57,12 @@ final class ClientBrowseUtils { private static final Logger LOGGER = LoggerFactory.getLogger(ClientBrowseUtils.class); + /** + * Upper bound on BrowseNext calls for a single continuation point, guarding against non-compliant + * servers that never return a null/empty continuation point. + */ + private static final int MAX_BROWSE_NEXT_ITERATIONS = 1000; + private ClientBrowseUtils() {} /** @@ -107,7 +122,13 @@ static List readWithOperationLimits( for (List partitionList : partition(readValueIds, partitionSize).toList()) { ReadResponse response = client.read(0.0, TimestampsToReturn.Neither, partitionList); DataValue[] results = response.getResults(); - Collections.addAll(values, requireNonNull(results)); + if (results == null || results.length != partitionList.size()) { + throw new UaException( + StatusCodes.Bad_UnexpectedError, + "Read returned %s results, expected %s" + .formatted(results == null ? "null" : results.length, partitionList.size())); + } + Collections.addAll(values, results); } return values; @@ -166,7 +187,15 @@ static List> browse( final var referenceDescriptionLists = new ArrayList>(); - for (BrowseResult result : client.browse(browseDescriptions)) { + List browseResults = client.browse(browseDescriptions); + if (browseResults.size() != browseDescriptions.size()) { + throw new UaException( + StatusCodes.Bad_UnexpectedError, + "Browse returned %d results, expected %d" + .formatted(browseResults.size(), browseDescriptions.size())); + } + + for (BrowseResult result : browseResults) { if (result.getStatusCode().isGood()) { var references = new ArrayList(); @@ -200,10 +229,32 @@ static List maybeBrowseNext( var references = new ArrayList(); + int iterations = 0; + while (continuationPoint != null && continuationPoint.isNotNull()) { + if (++iterations > MAX_BROWSE_NEXT_ITERATIONS) { + var limitException = + new UaException( + StatusCodes.Bad_UnexpectedError, + "BrowseNext did not complete after %d calls".formatted(MAX_BROWSE_NEXT_ITERATIONS)); + + try { + client.browseNext(true, List.of(continuationPoint)); + } catch (UaException e) { + limitException.addSuppressed(e); + } + + throw limitException; + } + BrowseNextResponse response = client.browseNext(false, List.of(continuationPoint)); - BrowseResult result = requireNonNull(response.getResults())[0]; + BrowseResult[] results = response.getResults(); + if (results == null || results.length == 0) { + throw new UaException(StatusCodes.Bad_UnexpectedError, "BrowseNext returned no results"); + } + + BrowseResult result = results[0]; ReferenceDescription[] rds = requireNonNullElse(result.getReferences(), new ReferenceDescription[0]); @@ -215,4 +266,99 @@ static List maybeBrowseNext( return references; } + + /** + * Browse the HasEncoding references of {@code dataTypeIds} to find their encoding Nodes. + * + * @param client the OPC UA client. + * @param dataTypeIds the {@link NodeId}s of the DataType Nodes to browse. + * @param limits the operation limits from the server. + * @return a list of reference description lists, one per DataType id. + * @throws UaException if a service-level error occurs. + */ + static List> browseEncodings( + OpcUaClient client, List dataTypeIds, OperationLimits limits) throws UaException { + + List browseDescriptions = + dataTypeIds.stream() + .map( + dataTypeId -> + new BrowseDescription( + dataTypeId, + BrowseDirection.Forward, + NodeIds.HasEncoding, + false, + uint(NodeClass.Object.getValue()), + uint(BrowseResultMask.All.getValue()))) + .toList(); + + return browseWithOperationLimits(client, browseDescriptions, limits); + } + + /** The encoding Node ids of a DataType, extracted from its HasEncoding references. */ + record EncodingIds( + @Nullable NodeId binaryEncodingId, + @Nullable NodeId xmlEncodingId, + @Nullable NodeId jsonEncodingId) {} + + /** + * Extract the Default Binary/XML/JSON encoding Node ids from a DataType Node's HasEncoding + * references. + * + * @param encodings the HasEncoding {@link ReferenceDescription}s of a DataType Node. + * @param namespaceTable the namespace table for converting ExpandedNodeIds. + * @return the extracted {@link EncodingIds}. + */ + static EncodingIds extractEncodingIds( + List encodings, NamespaceTable namespaceTable) { + + NodeId binaryEncodingId = null; + NodeId xmlEncodingId = null; + NodeId jsonEncodingId = null; + + for (ReferenceDescription r : encodings) { + // Observed multiple servers at IOP using the wrong namespace index... + // Be lenient and also allow matching on the unqualified browse name. + + if (r.getBrowseName().equals(DataTypeEncoding.BINARY_ENCODING_NAME) + || Objects.equals(r.getBrowseName().name(), "Default Binary")) { + + binaryEncodingId = r.getNodeId().toNodeId(namespaceTable).orElse(null); + } else if (r.getBrowseName().equals(DataTypeEncoding.XML_ENCODING_NAME) + || Objects.equals(r.getBrowseName().name(), "Default XML")) { + + xmlEncodingId = r.getNodeId().toNodeId(namespaceTable).orElse(null); + } else if (r.getBrowseName().equals(DataTypeEncoding.JSON_ENCODING_NAME) + || Objects.equals(r.getBrowseName().name(), "Default JSON")) { + + jsonEncodingId = r.getNodeId().toNodeId(namespaceTable).orElse(null); + } + } + + return new EncodingIds(binaryEncodingId, xmlEncodingId, jsonEncodingId); + } + + /** + * Get the Binary Encoding Node id for {@code dataType}. + * + *

Falls back to the DefaultEncodingId from the type's {@link StructureDefinition} as a + * workaround for non-compliant Servers that don't have encoding nodes in their address space. The + * DefaultEncodingId in a StructureDefinition shall always be the Default Binary encoding, so use + * it if the Server at least set that correctly. See Part 3, 8.48. + * + * @param dataType the {@link DataType} to get the Binary Encoding Node id for. + * @return the Binary Encoding Node id, or {@code null} if none is available. + */ + static @Nullable NodeId getBinaryEncodingId(DataType dataType) { + NodeId binaryEncodingId = dataType.getBinaryEncodingId(); + + if (binaryEncodingId == null + && dataType.getDataTypeDefinition() instanceof StructureDefinition definition) { + + binaryEncodingId = definition.getDefaultEncodingId(); + } + + return binaryEncodingId; + } } diff --git a/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/ClientDataType.java b/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/ClientDataType.java index 941f36779f..4203ee538a 100644 --- a/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/ClientDataType.java +++ b/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/ClientDataType.java @@ -34,19 +34,19 @@ class ClientDataType implements DataType { private final QualifiedName browseName; private final NodeId nodeId; - private final NodeId binaryEncodingId; - private final NodeId xmlEncodingId; - private final NodeId jsonEncodingId; - private final DataTypeDefinition dataTypeDefinition; + private final @Nullable NodeId binaryEncodingId; + private final @Nullable NodeId xmlEncodingId; + private final @Nullable NodeId jsonEncodingId; + private final @Nullable DataTypeDefinition dataTypeDefinition; private final Boolean isAbstract; public ClientDataType( QualifiedName browseName, NodeId nodeId, - NodeId binaryEncodingId, - NodeId xmlEncodingId, - NodeId jsonEncodingId, - DataTypeDefinition dataTypeDefinition, + @Nullable NodeId binaryEncodingId, + @Nullable NodeId xmlEncodingId, + @Nullable NodeId jsonEncodingId, + @Nullable DataTypeDefinition dataTypeDefinition, Boolean isAbstract) { this.browseName = browseName; diff --git a/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/DataTypeManagerFactory.java b/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/DataTypeManagerFactory.java index df9919fcfa..9cdb857c5c 100644 --- a/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/DataTypeManagerFactory.java +++ b/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/DataTypeManagerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 the Eclipse Milo Authors + * Copyright (c) 2026 the Eclipse Milo Authors * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -22,8 +22,6 @@ import org.eclipse.milo.opcua.stack.core.UaRuntimeException; import org.eclipse.milo.opcua.stack.core.types.DataTypeManager; import org.eclipse.milo.opcua.stack.core.types.DefaultDataTypeManager; -import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; -import org.eclipse.milo.opcua.stack.core.types.structured.StructureDefinition; import org.eclipse.milo.opcua.stack.core.util.Tree; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -180,6 +178,14 @@ public DefaultInitializer(CodecFactory codecFactory) { public void initialize( NamespaceTable namespaceTable, DataTypeTree dataTypeTree, DataTypeManager dataTypeManager) { + if (dataTypeTree instanceof LazyClientDataTypeTree) { + LOGGER.warn( + "Eagerly initializing codecs against a lazy DataTypeTree; only types the tree has" + + " already resolved will be registered. Configure" + + " DataTypeManagerFactory.lazy() alongside DataTypeTreeFactory.lazy() for lazy" + + " codec resolution."); + } + Tree structureNode = dataTypeTree.getTreeNode(NodeIds.Structure); if (structureNode != null) { @@ -203,7 +209,7 @@ public void initialize( dataTypeManager.registerType( dataType.getNodeId(), codecFactory.create(dataType, dataTypeTree), - getBinaryEncodingId(dataType), + ClientBrowseUtils.getBinaryEncodingId(dataType), dataType.getXmlEncodingId(), dataType.getJsonEncodingId()); } @@ -214,23 +220,5 @@ public void initialize( + " hierarchy sane?"); } } - - private static NodeId getBinaryEncodingId(DataType dataType) { - NodeId binaryEncodingId = dataType.getBinaryEncodingId(); - - if (binaryEncodingId == null - && dataType.getDataTypeDefinition() instanceof StructureDefinition definition) { - - // Hail mary work around for non-compliant Servers that don't have encoding nodes - // in their address space. The DefaultEncodingId in a StructureDefinition shall - // always be the Default Binary encoding, so let's see if the Server at least set - // this correctly. - // See https://reference.opcfoundation.org/Core/Part3/v105/docs/8.48 - - binaryEncodingId = definition.getDefaultEncodingId(); - } - - return binaryEncodingId; - } } } diff --git a/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/DataTypeTreeBuilder.java b/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/DataTypeTreeBuilder.java index ca0f5f1853..f832ee7c54 100644 --- a/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/DataTypeTreeBuilder.java +++ b/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/DataTypeTreeBuilder.java @@ -14,7 +14,6 @@ import java.util.ArrayList; import java.util.List; -import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.stream.Collectors; @@ -26,7 +25,6 @@ import org.eclipse.milo.opcua.stack.core.NamespaceTable; import org.eclipse.milo.opcua.stack.core.NodeIds; import org.eclipse.milo.opcua.stack.core.UaException; -import org.eclipse.milo.opcua.stack.core.types.DataTypeEncoding; import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue; import org.eclipse.milo.opcua.stack.core.types.builtin.ExtensionObject; import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; @@ -168,36 +166,16 @@ private static void addChildren( DataTypeDefinition dataTypeDefinition = dataTypeAttributes.get(j).definition; Boolean isAbstract = dataTypeAttributes.get(j).isAbstract; - NodeId binaryEncodingId = null; - NodeId xmlEncodingId = null; - NodeId jsonEncodingId = null; - - for (ReferenceDescription r : encodings) { - // Observed multiple servers at IOP using the wrong namespace index... - // Be lenient and also allow matching on the unqualified browse name. - - if (r.getBrowseName().equals(DataTypeEncoding.BINARY_ENCODING_NAME) - || Objects.equals(r.getBrowseName().name(), "Default Binary")) { - - binaryEncodingId = r.getNodeId().toNodeId(namespaceTable).orElse(null); - } else if (r.getBrowseName().equals(DataTypeEncoding.XML_ENCODING_NAME) - || Objects.equals(r.getBrowseName().name(), "Default XML")) { - - xmlEncodingId = r.getNodeId().toNodeId(namespaceTable).orElse(null); - } else if (r.getBrowseName().equals(DataTypeEncoding.JSON_ENCODING_NAME) - || Objects.equals(r.getBrowseName().name(), "Default JSON")) { - - jsonEncodingId = r.getNodeId().toNodeId(namespaceTable).orElse(null); - } - } + ClientBrowseUtils.EncodingIds encodingIds = + ClientBrowseUtils.extractEncodingIds(encodings, namespaceTable); var dataType = new ClientDataType( browseName, dataTypeId, - binaryEncodingId, - xmlEncodingId, - jsonEncodingId, + encodingIds.binaryEncodingId(), + encodingIds.xmlEncodingId(), + encodingIds.jsonEncodingId(), dataTypeDefinition, isAbstract); @@ -227,20 +205,7 @@ private static List> browseEncodings( ClientBrowseUtils.checkSessionUnchanged(client, sessionId); - List browseDescriptions = - dataTypeIds.stream() - .map( - dataTypeId -> - new BrowseDescription( - dataTypeId, - BrowseDirection.Forward, - NodeIds.HasEncoding, - false, - uint(NodeClass.Object.getValue()), - uint(BrowseResultMask.All.getValue()))) - .collect(Collectors.toList()); - - return ClientBrowseUtils.browseWithOperationLimits(client, browseDescriptions, operationLimits); + return ClientBrowseUtils.browseEncodings(client, dataTypeIds, operationLimits); } private static List<@Nullable Attributes> readDataTypeAttributes( diff --git a/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/DataTypeTreeFactory.java b/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/DataTypeTreeFactory.java index b89e0bd1c2..6cc0d31101 100644 --- a/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/DataTypeTreeFactory.java +++ b/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/DataTypeTreeFactory.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 the Eclipse Milo Authors + * Copyright (c) 2026 the Eclipse Milo Authors * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 diff --git a/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/LazyClientDataTypeManager.java b/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/LazyClientDataTypeManager.java index e8a863e2ab..affd3d301f 100644 --- a/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/LazyClientDataTypeManager.java +++ b/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/LazyClientDataTypeManager.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 the Eclipse Milo Authors + * Copyright (c) 2026 the Eclipse Milo Authors * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -32,7 +32,6 @@ import org.eclipse.milo.opcua.stack.core.types.enumerated.NodeClass; import org.eclipse.milo.opcua.stack.core.types.structured.BrowseDescription; import org.eclipse.milo.opcua.stack.core.types.structured.BrowseResult; -import org.eclipse.milo.opcua.stack.core.types.structured.StructureDefinition; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -77,6 +76,9 @@ public class LazyClientDataTypeManager extends DefaultDataTypeManager { private final DataTypeTree dataTypeTree; private final BiFunction codecFactory; private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + + // Read without the lock on fast paths; contains only ids whose resolution attempt has + // completed and failed, so an in-flight resolution is never mistaken for a failed one. private final Set attemptedResolution = ConcurrentHashMap.newKeySet(); /** @@ -156,18 +158,22 @@ public void clearFailedResolutions() { // Slow path: attempt lazy resolution under write lock lock.writeLock().lock(); try { - // Re-check in case another thread just resolved it + // Re-check in case another thread just resolved it or just failed to codec = super.getCodec(id); if (codec != null) { return codec; } - - // Mark as attempted before trying resolution - if (!attemptedResolution.add(id)) { + if (attemptedResolution.contains(id)) { return null; } - return resolveAndRegisterCodec(id); + codec = resolveAndRegisterCodec(id); + + if (codec == null) { + attemptedResolution.add(id); + } + + return codec; } finally { lock.writeLock().unlock(); } @@ -204,7 +210,7 @@ private void ensureRegisteredForDataType(NodeId dataTypeId) { return; } - // Skip if already attempted + // Skip if a previous attempt already failed if (attemptedResolution.contains(dataTypeId)) { return; } @@ -212,13 +218,24 @@ private void ensureRegisteredForDataType(NodeId dataTypeId) { // Slow path: attempt resolution under write lock lock.writeLock().lock(); try { - // Re-check after acquiring lock - if (attemptedResolution.contains(dataTypeId)) { + // Re-check in case another thread just registered it or just failed to + if (super.getBinaryEncodingId(dataTypeId) != null + || super.getXmlEncodingId(dataTypeId) != null + || super.getJsonEncodingId(dataTypeId) != null + || attemptedResolution.contains(dataTypeId)) { return; } - attemptedResolution.add(dataTypeId); resolveAndRegisterCodecFromDataTypeId(dataTypeId); + + boolean registered = + super.getBinaryEncodingId(dataTypeId) != null + || super.getXmlEncodingId(dataTypeId) != null + || super.getJsonEncodingId(dataTypeId) != null; + + if (!registered) { + attemptedResolution.add(dataTypeId); + } } finally { lock.writeLock().unlock(); } @@ -266,7 +283,7 @@ private void resolveAndRegisterCodecFromDataTypeId(NodeId dataTypeId) { } private @Nullable DataTypeCodec createAndRegisterCodec(DataType dataType) { - NodeId binaryEncodingId = getBinaryEncodingIdFromDataType(dataType); + NodeId binaryEncodingId = ClientBrowseUtils.getBinaryEncodingId(dataType); DataTypeCodec codec = codecFactory.apply(dataType, dataTypeTree); @@ -285,22 +302,6 @@ private void resolveAndRegisterCodecFromDataTypeId(NodeId dataTypeId) { return codec; } - private static @Nullable NodeId getBinaryEncodingIdFromDataType(DataType dataType) { - NodeId binaryEncodingId = dataType.getBinaryEncodingId(); - - if (binaryEncodingId == null - && dataType.getDataTypeDefinition() instanceof StructureDefinition definition) { - - // Workaround for non-compliant servers that don't have encoding nodes. - // The DefaultEncodingId in a StructureDefinition shall always be the Default Binary - // encoding. - // See https://reference.opcfoundation.org/Core/Part3/v105/docs/8.48 - binaryEncodingId = definition.getDefaultEncodingId(); - } - - return binaryEncodingId; - } - private @Nullable NodeId browseDataTypeIdForEncoding(NodeId encodingId) { try { BrowseDescription bd = diff --git a/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/LazyClientDataTypeTree.java b/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/LazyClientDataTypeTree.java index 48ba44b33a..af56aadf72 100644 --- a/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/LazyClientDataTypeTree.java +++ b/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/LazyClientDataTypeTree.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 the Eclipse Milo Authors + * Copyright (c) 2026 the Eclipse Milo Authors * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -13,11 +13,12 @@ import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; -import java.util.Objects; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.Supplier; import org.eclipse.milo.opcua.sdk.client.OpcUaClient; import org.eclipse.milo.opcua.sdk.client.OperationLimits; import org.eclipse.milo.opcua.sdk.core.typetree.DataType; @@ -27,7 +28,6 @@ import org.eclipse.milo.opcua.stack.core.NodeIds; import org.eclipse.milo.opcua.stack.core.OpcUaDataType; import org.eclipse.milo.opcua.stack.core.UaException; -import org.eclipse.milo.opcua.stack.core.types.DataTypeEncoding; import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue; import org.eclipse.milo.opcua.stack.core.types.builtin.ExtensionObject; import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; @@ -64,6 +64,9 @@ * that concurrent threads attempting to resolve different types will be serialized. Once a type is * resolved, later lookups only require the read lock and can proceed concurrently. * + *

Because the underlying {@link Tree} is mutated as types are resolved, {@link #getRoot()} and + * {@link #getTreeNode(NodeId)} return snapshot copies rather than live nodes. + * *

Resolution Behavior

* *

Resolution errors (e.g., network failures, non-existent types) do not cause exceptions to be @@ -83,9 +86,17 @@ public class LazyClientDataTypeTree extends DataTypeTree { private static final Logger LOGGER = LoggerFactory.getLogger(LazyClientDataTypeTree.class); + /** + * Upper bound on the number of inverse HasSubtype hops followed while resolving a type, guarding + * against non-compliant servers with cyclic or unbounded inverse subtype references. + */ + private static final int MAX_RESOLUTION_DEPTH = 256; + private final OpcUaClient client; private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); - private final Set attemptedResolution = ConcurrentHashMap.newKeySet(); + + // All access is guarded by `lock`: reads under the read lock, mutations under the write lock. + private final Set attemptedResolution = new HashSet<>(); private volatile NamespaceTable namespaceTable; @@ -230,6 +241,10 @@ private void ensureResolved(NodeId dataTypeId) { resolvePath(dataTypeId); } catch (UaException e) { LOGGER.debug("Failed to resolve DataType {}: {}", dataTypeId, e.getMessage()); + } catch (RuntimeException e) { + // Query methods are documented not to throw on resolution failure; an unexpected + // RuntimeException here usually indicates a non-compliant server response. + LOGGER.warn("Unexpected error resolving DataType {}", dataTypeId, e); } } finally { lock.writeLock().unlock(); @@ -237,12 +252,16 @@ private void ensureResolved(NodeId dataTypeId) { } private void resolvePath(NodeId dataTypeId) throws UaException { + // Pin resolution to the session it started on so results assembled across a session change + // (e.g. a reconnection mid-resolution) are never cached. + NodeId sessionId = client.getSession().getSessionId(); + NamespaceTable nsTable = getNamespaceTable(); OperationLimits limits = client.getOperationLimits(); List pathToResolve = browseInverseUntilKnown(dataTypeId, types.keySet(), nsTable); - if (pathToResolve.isEmpty() || pathToResolve.size() < 2) { + if (pathToResolve.size() < 2) { LOGGER.debug("Could not resolve path to known ancestor for DataType {}", dataTypeId); return; } @@ -253,23 +272,30 @@ private void resolvePath(NodeId dataTypeId) throws UaException { List dataTypes = fetchDataTypeInfoBatch(nodesToAdd, nsTable, limits); + ClientBrowseUtils.checkSessionUnchanged(client, sessionId); + // Add from ancestor toward target (reverse order) Tree parentTree = types.get(knownAncestorId); for (int i = nodesToAdd.size() - 1; i >= 0; i--) { ClientDataType dataType = dataTypes.get(i); - if (dataType != null && parentTree != null) { - Tree childTree = parentTree.addChild(dataType); - types.put(dataType.getNodeId(), childTree); - parentTree = childTree; - - LOGGER.debug("Resolved DataType: {}", dataType.getBrowseName().toParseableString()); + if (dataType == null) { + // Attribute reads failed for this node; stop here rather than caching an incomplete + // type or attaching its descendants to the wrong parent. + LOGGER.debug("Attribute reads failed for DataType {}; path not cached", nodesToAdd.get(i)); + break; } + + Tree childTree = parentTree.addChild(dataType); + types.put(dataType.getNodeId(), childTree); + parentTree = childTree; + + LOGGER.debug("Resolved DataType: {}", dataType.getBrowseName().toParseableString()); } } - private List fetchDataTypeInfoBatch( + private List<@Nullable ClientDataType> fetchDataTypeInfoBatch( List nodeIds, NamespaceTable nsTable, OperationLimits limits) throws UaException { // Read attributes: BrowseName, IsAbstract, DataTypeDefinition @@ -288,9 +314,10 @@ private List fetchDataTypeInfoBatch( ClientBrowseUtils.readWithOperationLimits(client, readValueIds, limits); // Browse encodings - List> encodingRefs = browseEncodings(nodeIds, limits); + List> encodingRefs = + ClientBrowseUtils.browseEncodings(client, nodeIds, limits); - var result = new ArrayList(); + var result = new ArrayList<@Nullable ClientDataType>(); for (int i = 0; i < nodeIds.size(); i++) { NodeId nodeId = nodeIds.get(i); @@ -300,31 +327,23 @@ private List fetchDataTypeInfoBatch( Boolean isAbstract = extractIsAbstract(values.get(valueOffset + 1)); DataTypeDefinition definition = extractDataTypeDefinition(values.get(valueOffset + 2)); - NodeId binaryEncodingId = null; - NodeId xmlEncodingId = null; - NodeId jsonEncodingId = null; - - for (ReferenceDescription r : encodingRefs.get(i)) { - // Be lenient: also match on unqualified browse name (some servers use wrong namespace) - if (r.getBrowseName().equals(DataTypeEncoding.BINARY_ENCODING_NAME) - || Objects.equals(r.getBrowseName().name(), "Default Binary")) { - binaryEncodingId = r.getNodeId().toNodeId(nsTable).orElse(null); - } else if (r.getBrowseName().equals(DataTypeEncoding.XML_ENCODING_NAME) - || Objects.equals(r.getBrowseName().name(), "Default XML")) { - xmlEncodingId = r.getNodeId().toNodeId(nsTable).orElse(null); - } else if (r.getBrowseName().equals(DataTypeEncoding.JSON_ENCODING_NAME) - || Objects.equals(r.getBrowseName().name(), "Default JSON")) { - jsonEncodingId = r.getNodeId().toNodeId(nsTable).orElse(null); - } + if (browseName == null) { + // BrowseName is a mandatory attribute; a bad read means the node is unavailable and the + // type would be cached with meaningless values. + result.add(null); + continue; } + ClientBrowseUtils.EncodingIds encodingIds = + ClientBrowseUtils.extractEncodingIds(encodingRefs.get(i), nsTable); + result.add( new ClientDataType( browseName, nodeId, - binaryEncodingId, - xmlEncodingId, - jsonEncodingId, + encodingIds.binaryEncodingId(), + encodingIds.xmlEncodingId(), + encodingIds.jsonEncodingId(), definition, isAbstract)); } @@ -344,10 +363,18 @@ private List fetchDataTypeInfoBatch( private List browseInverseUntilKnown( NodeId startId, Set knownTypeIds, NamespaceTable namespaceTable) { + var visited = new HashSet(); List path = new ArrayList<>(); NodeId current = startId; while (current != null && !knownTypeIds.contains(current)) { + if (!visited.add(current) || visited.size() > MAX_RESOLUTION_DEPTH) { + LOGGER.warn( + "Inverse HasSubtype references from {} are cyclic or exceed depth {}", + startId, + MAX_RESOLUTION_DEPTH); + return List.of(); + } path.add(current); current = browseInverseParent(current, namespaceTable); } @@ -393,30 +420,11 @@ private List browseInverseUntilKnown( return null; } - private List> browseEncodings( - List dataTypeIds, OperationLimits limits) throws UaException { - - List browseDescriptions = - dataTypeIds.stream() - .map( - dataTypeId -> - new BrowseDescription( - dataTypeId, - BrowseDirection.Forward, - NodeIds.HasEncoding, - false, - uint(NodeClass.Object.getValue()), - uint(BrowseResultMask.All.getValue()))) - .toList(); - - return ClientBrowseUtils.browseWithOperationLimits(client, browseDescriptions, limits); - } - - private static QualifiedName extractBrowseName(DataValue value) { + private static @Nullable QualifiedName extractBrowseName(DataValue value) { if (value.statusCode().isGood() && value.value().value() instanceof QualifiedName qn) { return qn; } - return QualifiedName.NULL_VALUE; + return null; } private static Boolean extractIsAbstract(DataValue value) { @@ -447,6 +455,23 @@ private static Boolean extractIsAbstract(DataValue value) { // ===== Overridden Methods ===== + /** + * Ensure {@code dataTypeId} is resolved, then evaluate {@code query} under the read lock. + * + * @param dataTypeId the {@link NodeId} of the DataType the query is about. + * @param query the query to evaluate. + * @return the result of {@code query}. + */ + private T resolvedQuery(NodeId dataTypeId, Supplier query) { + ensureResolved(dataTypeId); + lock.readLock().lock(); + try { + return query.get(); + } finally { + lock.readLock().unlock(); + } + } + /** * Get a snapshot of the root of the underlying {@link Tree} structure. * @@ -472,147 +497,109 @@ public Tree getRoot() { } } + /** + * Get a snapshot of the underlying {@link Tree} node for the DataType identified by {@code + * dataTypeId}. + * + *

Like {@link #getRoot()}, this method returns a node from a deep copy (snapshot) of the + * current tree state rather than the live, lazily-mutated tree. The snapshot is taken from the + * root, so the returned node's parent chain is intact. + * + * @param dataTypeId the {@link NodeId} of a DataType Node. + * @return a snapshot of the {@link Tree} node for the DataType identified by {@code dataTypeId}, + * or {@code null} if it is not present in the tree. + */ @Override - public boolean containsType(NodeId typeId) { - ensureResolved(typeId); + public @Nullable Tree getTreeNode(NodeId dataTypeId) { + ensureResolved(dataTypeId); lock.readLock().lock(); try { - return super.containsType(typeId); + if (super.getTreeNode(dataTypeId) == null) { + return null; + } + + Tree snapshot = tree.map(dataType -> dataType); + + var treeNode = new AtomicReference>(); + snapshot.traverseNodes( + node -> { + if (node.getValue().getNodeId().equals(dataTypeId)) { + treeNode.set(node); + } + }); + return treeNode.get(); } finally { lock.readLock().unlock(); } } + @Override + public boolean containsType(NodeId typeId) { + return resolvedQuery(typeId, () -> super.containsType(typeId)); + } + @Override public @Nullable DataType getType(NodeId nodeId) { - ensureResolved(nodeId); - lock.readLock().lock(); - try { - return super.getType(nodeId); - } finally { - lock.readLock().unlock(); - } + return resolvedQuery(nodeId, () -> super.getType(nodeId)); } @Override public Class getBackingClass(NodeId dataTypeId) { - ensureResolved(dataTypeId); - lock.readLock().lock(); - try { + if (hasStaticBackingClass(dataTypeId)) { + // The superclass answers without consulting the tree; skip resolution and locking. return super.getBackingClass(dataTypeId); - } finally { - lock.readLock().unlock(); } + return resolvedQuery(dataTypeId, () -> super.getBackingClass(dataTypeId)); } @Override public OpcUaDataType getBuiltinType(NodeId dataTypeId) { - ensureResolved(dataTypeId); - lock.readLock().lock(); - try { + if (OpcUaDataType.isBuiltin(dataTypeId)) { + // The superclass answers without consulting the tree; skip resolution and locking. return super.getBuiltinType(dataTypeId); - } finally { - lock.readLock().unlock(); } + return resolvedQuery(dataTypeId, () -> super.getBuiltinType(dataTypeId)); } @Override public @Nullable DataType getDataType(NodeId dataTypeId) { - ensureResolved(dataTypeId); - lock.readLock().lock(); - try { - return super.getDataType(dataTypeId); - } finally { - lock.readLock().unlock(); - } + return resolvedQuery(dataTypeId, () -> super.getDataType(dataTypeId)); } @Override public @Nullable NodeId getBinaryEncodingId(NodeId dataTypeId) { - ensureResolved(dataTypeId); - lock.readLock().lock(); - try { - return super.getBinaryEncodingId(dataTypeId); - } finally { - lock.readLock().unlock(); - } + return resolvedQuery(dataTypeId, () -> super.getBinaryEncodingId(dataTypeId)); } @Override public @Nullable NodeId getXmlEncodingId(NodeId dataTypeId) { - ensureResolved(dataTypeId); - lock.readLock().lock(); - try { - return super.getXmlEncodingId(dataTypeId); - } finally { - lock.readLock().unlock(); - } + return resolvedQuery(dataTypeId, () -> super.getXmlEncodingId(dataTypeId)); } @Override public @Nullable NodeId getJsonEncodingId(NodeId dataTypeId) { - ensureResolved(dataTypeId); - lock.readLock().lock(); - try { - return super.getJsonEncodingId(dataTypeId); - } finally { - lock.readLock().unlock(); - } + return resolvedQuery(dataTypeId, () -> super.getJsonEncodingId(dataTypeId)); } @Override public @Nullable DataTypeDefinition getDataTypeDefinition(NodeId dataTypeId) { - ensureResolved(dataTypeId); - lock.readLock().lock(); - try { - return super.getDataTypeDefinition(dataTypeId); - } finally { - lock.readLock().unlock(); - } + return resolvedQuery(dataTypeId, () -> super.getDataTypeDefinition(dataTypeId)); } @Override public boolean isAssignable(NodeId dataTypeId, Class clazz) { - ensureResolved(dataTypeId); - lock.readLock().lock(); - try { - return super.isAssignable(dataTypeId, clazz); - } finally { - lock.readLock().unlock(); - } + // Resolution and locking are handled by the getBackingClass override. + return super.isAssignable(dataTypeId, clazz); } @Override public boolean isEnumType(NodeId dataTypeId) { - ensureResolved(dataTypeId); - lock.readLock().lock(); - try { - return super.isEnumType(dataTypeId); - } finally { - lock.readLock().unlock(); - } + return resolvedQuery(dataTypeId, () -> super.isEnumType(dataTypeId)); } @Override public boolean isStructType(NodeId dataTypeId) { - ensureResolved(dataTypeId); - lock.readLock().lock(); - try { - return super.isStructType(dataTypeId); - } finally { - lock.readLock().unlock(); - } - } - - @Override - public @Nullable Tree getTreeNode(NodeId dataTypeId) { - ensureResolved(dataTypeId); - lock.readLock().lock(); - try { - return super.getTreeNode(dataTypeId); - } finally { - lock.readLock().unlock(); - } + return resolvedQuery(dataTypeId, () -> super.isStructType(dataTypeId)); } @Override @@ -620,9 +607,32 @@ public boolean isSubtypeOf(NodeId typeId, NodeId superTypeId) { ensureResolved(typeId); lock.readLock().lock(); try { - return super.isSubtypeOf(typeId, superTypeId); + // Walk the parent chain directly rather than delegating to the superclass, which would + // dispatch back through the overridden (snapshotting) getTreeNode. + Tree node = super.getTreeNode(typeId); + + while (node != null) { + Tree parent = node.getParent(); + if (parent == null) { + return false; + } + if (parent.getValue().getNodeId().equals(superTypeId)) { + return true; + } + node = parent; + } + + return false; } finally { lock.readLock().unlock(); } } + + private static boolean hasStaticBackingClass(NodeId dataTypeId) { + return OpcUaDataType.isBuiltin(dataTypeId) + || NodeIds.Enumeration.equals(dataTypeId) + || NodeIds.Number.equals(dataTypeId) + || NodeIds.Integer.equals(dataTypeId) + || NodeIds.UInteger.equals(dataTypeId); + } } diff --git a/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/LazyClientDataTypeTreeSeed.java b/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/LazyClientDataTypeTreeSeed.java index 565b9e8d6e..4b83604b6a 100644 --- a/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/LazyClientDataTypeTreeSeed.java +++ b/opc-ua-sdk/sdk-client/src/main/java/org/eclipse/milo/opcua/sdk/client/typetree/LazyClientDataTypeTreeSeed.java @@ -25,7 +25,6 @@ public final class LazyClientDataTypeTreeSeed { private LazyClientDataTypeTreeSeed() {} - @SuppressWarnings("unused") public static Tree createSeedTree() { Tree root = new Tree<>(