Skip to content

fix: add defensive cycle guard to prerequisite evaluation#582

Draft
tanderson-ld wants to merge 1 commit into
mainfrom
ta/SDK-2709-cycle-detection
Draft

fix: add defensive cycle guard to prerequisite evaluation#582
tanderson-ld wants to merge 1 commit into
mainfrom
ta/SDK-2709-cycle-detection

Conversation

@tanderson-ld

@tanderson-ld tanderson-ld commented Jul 21, 2026

Copy link
Copy Markdown

Summary

Adds defensive cycle detection to the recursive prerequisite walk in ClientImpl::VariationInternal, bringing the C++ client SDK's behavior into line with the LaunchDarkly server SDK evaluators, which have handled cyclic prerequisite graphs gracefully for years.

Tracker: SDK-2709. Parent: SDK-2695. Spec change: sdk-specs#246 (CSPE 1.2.5, 1.2.5.1, 1.2.5.2). Contract-test coverage: sdk-test-harness#384 (merged in v2.38.0). Companion PRs: android-client-sdk#377, ios-client-sdk#512, js-core#1816, flutter-client-sdk#329, dotnet-core#317.

Background

The LaunchDarkly service validates prerequisite graphs on every mutation and rejects any change that would produce a cycle, so under normal operation the SDK does not see a cyclic prerequisite graph. Server-side SDK evaluators nonetheless carry defensive cycle detection for exceptional cases — for example, delivery of updates out of order or a persisted state loaded from disk that predates a subsequent correction. This PR extends the same defensive posture to the C++ client SDK.

The fix

VariationInternal<T> now threads a lazily-allocated std::unordered_set<std::string>* through the recursion carrying the flag keys on the current evaluation path. Before descending into a prerequisite, the walker checks whether that key is already on the path; if so, it skips that edge and continues with remaining prerequisites at the same level.

  • Lazy allocation: variation calls on prereq-less flags (the common case) allocate zero collections. The std::unordered_set is created only when the walker descends into a prerequisite for the first time in a call, then reused for the rest of the walk.
  • Mutable insert / catch-and-rethrow erase: no per-descent copy. ancestors->insert(key) before recursing; if a nested call throws, the exception handler restores ancestors to its pre-descent state before rethrowing. This preserves the invariant "the set contains exactly the current path" even under exceptions.
  • Ancestor-set (current-path) semantics — this is deliberate. A prerequisite reached via multiple non-cyclic paths (a diamond A -> [B, C], B -> [D], C -> [D]) is not a cycle; each path should emit its own prerequisite event. A global visited-set would silently drop the second event; the ancestor-set pattern correctly emits D twice. The contract-test suite has a case guarding this.

The optional trailing parameter std::unordered_set<std::string>* visited = nullptr is added to VariationInternal<T>, so all existing callers (the public BoolVariation / IntVariation / StringVariation / DoubleVariation / JsonVariation and their *Detail counterparts) work unchanged. The recursive call now goes directly to VariationInternal<Value> rather than through JsonVariation, so the visited pointer can be threaded through — behavior is otherwise identical.

Caller-visible behavior on a cycle

The requested flag returns its cached value and reason unchanged. A client-side prerequisite cycle is not surfaced as MALFORMED_FLAG and does not fall back to the caller-provided default value. This differs from server-side behavior — the client already holds an authoritative pre-evaluated result from the server; only the ancillary event walk is affected by the cycle.

Files changed

  • libs/client-sdk/src/client_impl.hpp — added std::unordered_set<std::string>* optional parameter to VariationInternal<T> declaration, plus the <string> / <unordered_set> includes.
  • libs/client-sdk/src/client_impl.cpp — cycle guard implementation in VariationInternal<T>.
  • contract-tests/client-contract-tests/src/main.cpp — declares client-prereq-cycle-detection capability so the matching contract tests in sdk-test-harness v2.38.0+ activate for this SDK.

Verification

  • Build: cmake --build build --target launchdarkly-cpp-client → clean, no warnings.
  • Contract tests locally against harness v2.38.1: client-tests binary + sdk-test-harness v2.38.1876 total, 12 skipped, 864 ran, all passed. Both new suites (events/prerequisite events handle cycles and events/summary events/prerequisites/handles cycles) fire all 15 subtests including the deep-chain non-cyclic control, no failures.

The C++ client SDK does not have existing unit tests that exercise prerequisite walks (libs/client-sdk/tests/client_test.cpp currently covers only default-value paths with no flag data loaded). Rather than build new unit-test infrastructure for this PR, verification is provided by the contract-test suite, which now exercises the fix comprehensively.

Changelog

Updated prerequisite evaluation event emission to match server SDK behavior for cyclic prerequisite graphs.

Adds an ancestor-set (current-path) cycle guard to the recursive
prerequisite walk in VariationInternal, bringing the C++ client SDK's
behavior into line with the LaunchDarkly server SDK evaluators, which
have detected and gracefully handled cyclic prerequisite graphs for
years.

The unordered_set tracking ancestor keys is allocated lazily:
variation calls on prereq-less flags allocate zero collections. Once
created, the set is shared for the rest of the walk via
insert-before-recurse / erase-after-recurse, guarded by a catch-and-
rethrow so an exception below cannot leave a stale ancestor entry
visible to a sibling branch.

When a cycle is detected the requested flag's cached value and reason
are returned unchanged; only the recursive prerequisite event walk is
affected.

Also declares the client-prereq-cycle-detection capability so the
matching sdk-test-harness contract tests activate for this SDK.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant