Skip to content

fix: add defensive cycle guard to prerequisite evaluation - #54

Merged
tanderson-ld merged 5 commits into
mainfrom
ta/SDK-2710-cycle-detection
Jul 23, 2026
Merged

fix: add defensive cycle guard to prerequisite evaluation#54
tanderson-ld merged 5 commits into
mainfrom
ta/SDK-2710-cycle-detection

Conversation

@tanderson-ld

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

Copy link
Copy Markdown
Contributor

Summary

Adds defensive cycle detection to the recursive prerequisite walk in variationDetail (LaunchDarklyClient.brs), bringing the Roku client SDK's behavior into line with the LaunchDarkly server SDK evaluators.

Tracker: SDK-2710. 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, cpp-sdks#582.

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 Roku client SDK.

The fix

variationDetail now threads a lazily-allocated associative array 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 no associative array. The map 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 add/delete around the loop: no per-descent copy. The current flag's key is inserted before the loop and removed after, so the invariant "the map contains exactly the current path" holds across sibling descents.
  • 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. There is a unit test guarding this.

The optional trailing parameter launchDarklyParamVisited=invalid is added to variationDetail, so existing callers (boolVariation, intVariationDetail, stringVariationDetail, doubleVariationDetail, jsonVariationDetail, and the untyped variation) work unchanged.

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

  • rawsrc/LaunchDarklyClient.brs — cycle guard in variationDetail.
  • src/test/source/tests/Test__Client.brs — 5 new unit tests: self-loop, two-cycle (evaluating each end), three-cycle, and a non-cyclic diamond that asserts the shared descendant emits an event for each path (the ancestor-set regression fence).
  • src/contract-tests/components/HttpServerTask.brs — declares client-prereq-cycle-detection so the matching contract tests in sdk-test-harness v2.38.0+ activate for this SDK.

Verification

  • Unit tests: 5 new TestCase__Client_CycleDetection_* cases follow the existing TestCase__Client_* pattern (seed client.private.store, call client.variation(), flush and inspect the event queue). Local execution requires a Roku device (ukor test); CI provides the actual test run.
  • Contract tests: local run requires deployment to a Roku device; CI provides the run against the released harness. The new suite (client-prereq-cycle-detection) will activate as soon as CI picks up harness v2.38.0+.

Changelog

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


Note

Medium Risk
Touches the prerequisite event-walk in core flag evaluation, but behavior for normal graphs is preserved and cycles only skip redundant descent; coverage is strong via new unit and contract capability tests.

Overview
Adds defensive cycle detection when the Roku client walks flag prerequisites to emit evaluation events, matching other LaunchDarkly SDKs and avoiding unbounded recursion on malformed graphs.

Core variationDetail logic moves into LaunchDarklyClientSharedPrivateFunctions and is merged into both the standard client and SceneGraph client. The recursive walk threads a current-path ancestor set (launchDarklyParamVisited): before descending into a prerequisite, if that flag is already on the path the edge is skipped; diamonds still emit events per path (not a global visited set). Public variation APIs are unchanged—they delegate to the private implementation with m.status.

Contract tests advertise capability client-prereq-cycle-detection. Five unit tests cover self-loop, 2- and 3-cycles, and a diamond graph (cached values unchanged; feature event order asserted).

Reviewed by Cursor Bugbot for commit 1e59528. Bugbot is set up for automated code reviews on this repo. Configure here.

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

The associative array tracking ancestor keys is allocated lazily:
variation calls on prereq-less flags allocate no map. Once created,
the map is shared for the rest of the walk via add-before-recurse /
delete-after-recurse, so an in-progress descent maintains the
invariant that the map contains exactly the current path.

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.
@tanderson-ld
tanderson-ld marked this pull request as ready for review July 22, 2026 17:49
@tanderson-ld
tanderson-ld requested a review from a team as a code owner July 22, 2026 17:49

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 04aa234. Configure here.

Comment thread src/test/source/tests/Test__Client.brs Outdated
Roku's variationDetail emits the current flag's feature event before
recursing into prerequisites (parent-first). Other client SDKs (.NET,
JS, Flutter, Android, iOS, C++) recurse first and emit deepest-first.
The test assertions here were initially written to match the other
SDKs; correct them to reflect Roku's actual behavior. A separate
ticket tracks aligning Roku's ordering with the other SDKs.
Comment thread rawsrc/LaunchDarklyClient.brs Outdated
function LaunchDarklyClientSharedFunctions(launchDarklyParamSceneGraphNode as Object) as Object
return {
variationDetail: function(launchDarklyParamFlagKey as String, launchDarklyParamFallback as Dynamic, launchDarklyParamEmbedReason=true as Boolean, launchDarklyParamStrong=invalid as Dynamic) as Object
variationDetail: function(launchDarklyParamFlagKey as String, launchDarklyParamFallback as Dynamic, launchDarklyParamEmbedReason=true as Boolean, launchDarklyParamStrong=invalid as Dynamic, launchDarklyParamVisited=invalid as Object) as Object

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At some point, we should think about only exposing a wrapper to this method. If someone provided an unexpected type to the param visited field, this would crash.

We have the same problem now with the param strong, which is why I don't think we need to fundamentally change this. But in case you decided to start your roku dev journey, I thought I would mention it. 😄

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed 1e59528 — encapsulates the recursive evaluator so visited doesn't leak into the public API. Approach:

  • New file-scope factory LaunchDarklyClientSharedPrivateFunctions() holds the impl. Each client factory (LaunchDarklyClient and LaunchDarklySG) appends it into its own m.private block via .private.append(...), so m.private.variationDetail is reachable in both variants without duplicating the source.
  • Public variationDetail / variation / typed *VariationDetail methods become thin wrappers over m.private.variationDetail(m.status, ...). The status object is passed in so the initialized-check inside the impl doesn't need the full merged client.
  • Strong is preserved on the public signature per your note — non-breaking. Only visited is removed from the public surface.

Verified on a Roku Express (firmware 15.2.4.3442):

  • Unit tests: 93 Success, 0 Fail, no runtime errors, all five CycleDetection_* cases pass
  • SG-path smoke: AppScene's m.ld.variation("hello-roku", false) returns cleanly (no Member function not found on the SG client's m.private)
  • Contract tests: 784 total, 30 skipped, 754 ran, all passed

Moves the recursive evaluation impl into a file-scope factory
(LaunchDarklyClientSharedPrivateFunctions) that each client factory
appends into its m.private block. The public variationDetail wrapper
delegates through m.private.variationDetail, so the new visited
parameter never appears in the public signature.

Strong is preserved on the public signature to avoid an unnecessary
breaking API change.
@tanderson-ld
tanderson-ld merged commit d4d325f into main Jul 23, 2026
6 checks passed
@tanderson-ld
tanderson-ld deleted the ta/SDK-2710-cycle-detection branch July 23, 2026 19:49
tanderson-ld added a commit that referenced this pull request Jul 23, 2026
## Summary

Aligns Roku's prerequisite event emission order with the rest of the
LaunchDarkly SDK family. `variationDetail` in `LaunchDarklyClient.brs`
now emits the current flag's feature event **after** the recursive
prerequisite walk instead of before it. For a flag `A` with prerequisite
`B`, events now land as `[B, A]` (matching every other client and server
SDK) rather than `[A, B]`.

Tracker: [SDK-2745](https://launchdarkly.atlassian.net/browse/SDK-2745).
Stacked on
[#54](#54).

## Motivation

A survey during the cycle-detection work in #54 found that Roku was the
only outlier across all client SDKs (.NET, JS, Flutter, Android, iOS,
C++) and every server SDK (Go, .NET, JS, Java, Python, Ruby, C++) — all
of which emit prerequisite events before the parent's event.
LaunchDarkly's event ingestion does not depend on intra-batch ordering,
so the pre-existing behavior wasn't a functional bug, but the divergence
is a cross-SDK anomaly worth correcting.

## Changes

- `rawsrc/LaunchDarklyClient.brs`: move the
`handleEventsForEval(launchDarklyLocalState)` call to after the
prerequisite recursion block.
- `src/test/source/tests/Test__Client.brs`: update the four
cycle-detection tests to expect deepest-first ordering (e.g., `[flagB,
flagA]` for a two-cycle evaluating A).

## Test plan

- [x] Unit tests pass on a Roku Express device (all `TestSuite__Client`
cases, including the five `CycleDetection_*` tests).
- [x] SDK contract tests pass against the harness: 784 total, 30
skipped, 754 ran, all passed. This includes every `events/summary
events/prerequisites/handles cycles/*` and `events/prerequisite events
handle cycles/*` case.
- [x] Ordering change is confirmed on device: two-cycle now emits `[B,
A]`.

[SDK-2745]:
https://launchdarkly.atlassian.net/browse/SDK-2745?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Single reorder of event emission in `variationDetail`; flag evaluation
and cycle detection are untouched, and ingestion does not rely on
intra-batch event order.
> 
> **Overview**
> **`variationDetail`** in `LaunchDarklyClient.brs` now calls
**`handleEventsForEval`** *after* walking prerequisites instead of
before. Prerequisite feature events are emitted **deepest-first** (e.g.
`B` then `A` when `A` depends on `B`), matching other LaunchDarkly
client and server SDKs; Roku had been parent-first.
> 
> The four **`CycleDetection_*`** tests in `Test__Client.brs` were
updated to assert the new event key order for two-cycles, three-cycles,
and the diamond graph (including duplicate `D` events on separate
paths). Evaluation values and cycle-guard behavior are unchanged.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
3de34ff. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
tanderson-ld added a commit that referenced this pull request Jul 23, 2026
🤖 I have created a release *beep* *boop*
---


##
[2.4.1](2.4.0...2.4.1)
(2026-07-23)


### Bug Fixes

* add defensive cycle guard to prerequisite evaluation
([ceab01d](ceab01d))
* add defensive cycle guard to prerequisite evaluation
([#54](#54))
([d4d325f](d4d325f))
* emit prerequisite events deepest-first
([d729aa7](d729aa7))
* emit prerequisite events deepest-first
([#55](#55))
([3fe85d1](3fe85d1))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Diff is only version manifest and changelog; no runtime code changes
in this PR.
> 
> **Overview**
> **Release 2.4.1** — bumps the package version in
`.release-please-manifest.json` from **2.4.0** to **2.4.1** and adds the
**2.4.1** section to `CHANGELOG.md` (Release Please).
> 
> The changelog records two prerequisite-evaluation fixes already landed
on main: a **cycle guard** so cyclic prerequisite graphs stop recursing
instead of running without bound, and **deepest-first** ordering when
emitting prerequisite feature events (aligned with client contract
expectations).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
b6d0e87. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
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.

2 participants