Skip to content

Lazy DataTypeTree Hardening - #1831

Merged
kevinherron merged 8 commits into
mainfrom
lazy-data-type-tree-hardening
Aug 1, 2026
Merged

Lazy DataTypeTree Hardening#1831
kevinherron merged 8 commits into
mainfrom
lazy-data-type-tree-hardening

Conversation

@kevinherron

Copy link
Copy Markdown
Contributor

Fixes a set of correctness, concurrency, and robustness issues found in a review of the lazy DataTypeTree / DataTypeManager implementation, plus some deduplication and test cleanups. One commit per logical change; each commit compiles and passes tests on its own.

Correctness / concurrency

  • getTreeNode() returned the live, lazily-mutated tree node (unlike getRoot(), which snapshots). Callers traversing it — including DataTypeManagerFactory.DefaultInitializer — raced resolvePath()'s addChild() and could see ConcurrentModificationException. It now returns a snapshot with the parent chain intact; isSubtypeOf is reimplemented as a direct parent walk so internal subtype checks don't pay for snapshots.
  • Codec resolution race in LazyClientDataTypeManager: attemptedResolution was checked outside the lock and populated before attempting resolution, so a thread observing an in-flight resolution returned a spurious null codec. Only completed failures are recorded now, with fast-path checks re-validated under the write lock.
  • Mid-session tree refresh left stale codecs: the dynamic DataTypeManager and EncodingContext capture the tree they were built against (each dynamic codec embeds it), so readDataTypeTree() / setDataTypeTreeFactory() left decodes running against the discarded tree. resetDataTypeTree() now cascades, source before derivatives.
  • ensureResolved() only caught UaException, so RuntimeExceptions from malformed server responses escaped query methods documented to return null on resolution failure.
  • Failed attribute reads were cached permanently (NULL browse name, null definition), and a failed node mid-path could silently attach its descendants to the wrong parent. A failed node now ends the path and nothing is cached for it.
  • Lazy resolution is now session-pinned like DataTypeTreeBuilder, so results assembled across a reconnect are never cached.

Robustness against non-compliant servers

  • Cyclic inverse HasSubtype references no longer loop forever under the write lock (visited set + depth cap).
  • BrowseNext is bounded against servers that echo continuation points forever, and no longer indexes an empty results array unchecked.
  • Browse/Read result counts are validated; mismatched counts previously misaligned positional lookups and could cache encoding ids against the wrong DataType.

Efficiency / cleanup

  • Builtin-id queries (getBuiltinType(Int32) etc.) no longer trigger network resolution for answers the superclass resolves statically.
  • The IOP encoding-name leniency, HasEncoding browse construction, and DefaultEncodingId fallback each existed in two copies across the eager and lazy paths; they're now shared helpers in ClientBrowseUtils.
  • The fourteen copy-pasted query overrides collapse into a single resolvedQuery helper.
  • Eagerly initializing codecs against a lazy tree (which registers zero custom codecs) now logs a warning instead of failing silently.
  • @Nullable annotations, header years, a stale @SuppressWarnings, and test cleanups (vacuous assertions replaced, fixtures extracted).

🤖 Generated with Claude Code

kevinherron and others added 7 commits July 29, 2026 16:29
Validate Browse/Read result counts (a non-compliant server returning
mismatched counts silently misaligned positional lookups, caching
encoding ids against the wrong DataType), bound BrowseNext against
servers that echo continuation points forever, and guard the empty
results array it indexed unchecked.

Also hoist the lenient IOP encoding-name matching, the HasEncoding
browse construction, and the StructureDefinition DefaultEncodingId
fallback into shared helpers so the eager and lazy paths can't drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
attemptedResolution was checked outside the lock and populated before
attempting resolution, so a thread observing another thread's in-flight
resolution returned a spurious null codec for a type that resolved
milliseconds later. Record only completed failures and re-validate the
fast-path checks under the write lock.

Also adopt the shared getBinaryEncodingId helper in place of the
duplicated DefaultEncodingId fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- catch RuntimeException in ensureResolved: query methods are
  documented to return null on resolution failure, but malformed
  server responses could throw through them mid-decode
- bound inverse HasSubtype traversal with a visited set and depth
  cap; cyclic references looped forever while holding the write lock
- pin resolution to the session it started on, like
  DataTypeTreeBuilder, so results spanning a reconnect are not cached
- stop caching types whose mandatory attribute reads fail; a failed
  node now ends the path instead of silently attaching its
  descendants to the wrong parent
- return a snapshot from getTreeNode: the live node exposed callers
  (e.g. DataTypeManagerFactory.DefaultInitializer) to concurrent
  modification during traversal; isSubtypeOf becomes a direct parent
  walk so internal subtype checks don't pay for snapshots
- answer builtin-id queries from the superclass's static tables
  instead of triggering network resolution first
- collapse the fourteen copied query overrides into a resolvedQuery
  helper and adopt the shared encoding helpers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DataTypeTreeFactory.lazy() combined with the default eager
DataTypeManagerFactory registers zero custom codecs: the lazy tree's
Structure node has no children to traverse at initialization time.
Every dynamic decode then fails with nothing pointing at the
misconfiguration. Also adopt the shared getBinaryEncodingId helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dynamic DataTypeManager and EncodingContext capture the tree they
were built against (each dynamic codec embeds it for field-hint
resolution), so a mid-session readDataTypeTree() or
setDataTypeTreeFactory() left decodes running against the discarded
tree. Cascade the reset in resetDataTypeTree(), source before
derivatives, so a racing rebuild can never capture the old tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ClientDataType's encoding id and definition parameters and fields
genuinely accept null but were unannotated. Remove a stale
@SuppressWarnings("unused") from createSeedTree, and correct header
years on files added in 2026.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
clearFailedResolutionsAllowsRetry asserted an always-true disjunction;
deleting the call under test would not have failed it. Assert exact
outcomes with a control assertion instead. Rename
diagnosticTestForStructure to describe the behavior it tests, and
extract the repeated tree construction into fixture helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kevinherron
kevinherron force-pushed the lazy-data-type-tree-hardening branch from 7aef3c1 to 761ec30 Compare July 29, 2026 23:29
@kevinherron kevinherron changed the title Harden the lazy DataTypeTree implementation Lazy DataTypeTree Hardening Jul 30, 2026
Free the server-side continuation point before aborting a BrowseNext
sequence that exceeds the configured iteration limit.
@kevinherron
kevinherron marked this pull request as ready for review August 1, 2026 14:39
@kevinherron
kevinherron merged commit 15c0795 into main Aug 1, 2026
6 checks passed
@kevinherron kevinherron added this to the 1.1.7 milestone Aug 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant