fix: add defensive cycle guard to prerequisite evaluation - #54
Conversation
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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ 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.
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.
| 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 |
There was a problem hiding this comment.
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. 😄
There was a problem hiding this comment.
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 (LaunchDarklyClientandLaunchDarklySG) appends it into its ownm.privateblock via.private.append(...), som.private.variationDetailis reachable in both variants without duplicating the source. - Public
variationDetail/variation/ typed*VariationDetailmethods become thin wrappers overm.private.variationDetail(m.status, ...). The status object is passed in so the initialized-check inside the impl doesn't need the full merged client. Strongis preserved on the public signature per your note — non-breaking. Onlyvisitedis 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'sm.ld.variation("hello-roku", false)returns cleanly (noMember function not foundon the SG client'sm.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.
## 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 -->
🤖 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 -->

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
variationDetailnow 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.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=invalidis added tovariationDetail, so existing callers (boolVariation,intVariationDetail,stringVariationDetail,doubleVariationDetail,jsonVariationDetail, and the untypedvariation) 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_FLAGand 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 invariationDetail.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— declaresclient-prereq-cycle-detectionso the matching contract tests in sdk-test-harness v2.38.0+ activate for this SDK.Verification
TestCase__Client_CycleDetection_*cases follow the existingTestCase__Client_*pattern (seedclient.private.store, callclient.variation(), flush and inspect the event queue). Local execution requires a Roku device (ukor test); CI provides the actual test run.client-prereq-cycle-detection) will activate as soon as CI picks up harness v2.38.0+.Changelog
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
variationDetaillogic moves intoLaunchDarklyClientSharedPrivateFunctionsand 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 withm.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.