Repository navigation
feat: [SDK-5581] share startup flags latching across hosts - #29
Conversation
- StartupFlagsLatch reads a host-provided IStartupFlagsStore once per process and mirrors the APP_STARTUP subset of remote flags back to it, writing only when it changed. - StartupFlags reports where the values came from (store, missing, unreadable, no store) and rejects non APP_STARTUP flags. - FeatureManager.refreshAtStartup latches APP_STARTUP flags from that snapshot, so isEnabled matches code that ran before init. Later refreshes never latch APP_STARTUP flags from remote keys. Co-authored-by: Cursor <cursoragent@cursor.com>
- Mark IStartupFlagsStore.read/write and StartupFlagsLatch.mirror @throws, so a Swift store can report an unreadable store or failed write instead of crashing, and Swift callers can catch mirror failures. - StartupFlags keys are canonical and empty unless the source is STORE, so isEnabled and refreshAtStartup can never report a flag on from a missing or unreadable store. - Point the FeatureManager class doc at refreshAtStartup, and note that store implementations must not call back into the latch. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Multi-model review (Opus 5.5, GPT 5.6 Sol, Grok 4.7) on 355 lines of Kotlin (FeatureManager + StartupFlags + tests).
The intended snapshot → refreshAtStartup → refresh(false) → mirror path matches the tests. The state machine does not enforce that order, and store I/O is not failure-safe.
Act on
- Any earlier
refresh()latches APP_STARTUP from remote; a laterrefreshAtStartuponly storesstartupKeysand cannot apply the snapshot (3/3). mirrorlets a throwingread()skipwrite, so a corrupt store staysUNREADABLEand every later process stays all-off (Opus, verified).
Consider
snapshotruns the host lambda andstore.read()under non-reentrantNSLock; a thrown provider also leavessnapshotnull, so the next call can disagree (3/3).mirroris an unsynchronized RMW against the same storesnapshotreads (3/3).
Noted: first upgrade run is MISSING/all-off even if cache has flags (current catalog is absent=off). localOverrides can diverge FeatureManager from the pre-init snapshot. appStartupKeysIn / Collection<String> widen the Swift surface.
Dismissed: Kotlin/NSSet equality as a correctness bug (speculative; extra writes from raw vs canonical keys are self-healing).
Sent by Cursor Automation: PR Reviews
| startupKeys = startup.keys | ||
| refreshLocked(remoteKeys, true, localOverrides) | ||
| } | ||
|
|
||
| // Callers hold [lock]; PlatformLock is not reentrant on iOS. | ||
| private fun refreshLocked( | ||
| remoteKeys: List<String>, | ||
| applyAppStartupFlags: Boolean, | ||
| localOverrides: List<String>, | ||
| ): List<DeferredFeatureActivation> { | ||
| val overrideKeys = localOverrides.mapTo(HashSet()) { canonicalizeFeatureFlagId(it) } | ||
| val enabledKeys = remoteKeys.mapTo(HashSet()) { canonicalizeFeatureFlagId(it) } + overrideKeys | ||
| val startup = startupKeys | ||
|
|
||
| val nextStates = featureStates.toMutableMap() | ||
| val deferred = mutableListOf<DeferredFeatureActivation>() | ||
| for (feature in FeatureFlag.entries) { | ||
| val desired = feature.isEnabledIn(enabledKeys) | ||
| when (feature.activationMode) { | ||
| FeatureActivationMode.IMMEDIATE -> nextStates[feature] = desired | ||
| FeatureActivationMode.APP_STARTUP -> { | ||
| // After the first refresh every catalog flag is present in | ||
| // [featureStates], including those that resolved to false. | ||
| val alreadyLatched = nextStates.containsKey(feature) | ||
| val applyStartup = | ||
| !alreadyLatched || | ||
| (applyAppStartupFlags && !hasCompletedFirstRefresh) | ||
| if (applyStartup) { | ||
| nextStates[feature] = desired | ||
| } else { | ||
| val latched = nextStates[feature] ?: false | ||
| if (latched != desired) { | ||
| deferred.add( | ||
| DeferredFeatureActivation( | ||
| key = feature.key, | ||
| desiredEnabled = desired, | ||
| latchedEnabled = latched, | ||
| ), | ||
| ) | ||
| } | ||
| val nextStates = featureStates.toMutableMap() | ||
| val deferred = mutableListOf<DeferredFeatureActivation>() | ||
| for (feature in FeatureFlag.entries) { | ||
| val desired = feature.isEnabledIn(enabledKeys) | ||
| when (feature.activationMode) { | ||
| FeatureActivationMode.IMMEDIATE -> nextStates[feature] = desired | ||
| FeatureActivationMode.APP_STARTUP -> { | ||
| // After the first refresh every catalog flag is present in | ||
| // [featureStates], including those that resolved to false. | ||
| val alreadyLatched = nextStates.containsKey(feature) | ||
| val applyStartup = | ||
| !alreadyLatched || | ||
| (applyAppStartupFlags && !hasCompletedFirstRefresh) | ||
| if (applyStartup) { | ||
| nextStates[feature] = | ||
| if (startup != null) feature.key in startup || feature.key in overrideKeys else desired |
There was a problem hiding this comment.
Act on (3/3): the first refresh() latches every APP_STARTUP flag from remote via !alreadyLatched while startupKeys is still null. A later refreshAtStartup assigns startupKeys here, then refreshLocked sees hasCompletedFirstRefresh and a full map, so the snapshot is ignored.
That is the mismatch this PR is meant to prevent: pre-init StartupFlagsLatch.snapshot and later FeatureManager.isEnabled can disagree. refresh(true) after a real refreshAtStartup is already safe; the other order is not. Make the first refreshAtStartup the only APP_STARTUP writer, or fail if a refresh already ran.
| remoteKeys: Collection<String>, | ||
| ): Boolean { | ||
| val wanted = appStartupKeysIn(remoteKeys) | ||
| if (store.read() == wanted) return false |
There was a problem hiding this comment.
Act on (Opus, verified) / consider race (3/3): store.read() is unguarded. If it throws, write(wanted) never runs, so every later process snapshots UNREADABLE and stays all-off. Treat a failed read as “changed” and still write.
The read/compare/write is also unlocked, so two mirrors or a snapshot overlapping a write can persist a stale set or cache UNREADABLE for the process.
| lock.withLock { | ||
| snapshot ?: read(store()).also { snapshot = it } |
There was a problem hiding this comment.
Consider (3/3): store() and store.read() run under PlatformLock (iOS NSLock is not reentrant). Re-entry from the provider deadlocks on iOS and can be hidden by Android synchronized.
store() is also outside read()’s try/catch, and snapshot has no @Throws. A thrown provider leaves snapshot null, so the next call can return a different value and break “first snapshot wins, including an unreadable one.” Resolve the store / read outside the lock and map provider failure to UNREADABLE.
|
I would address these:
|
- Mirror writes when the stored file can't be read. - refreshAtStartup latches the snapshot even after an earlier refresh. - Mirror serializes under its own lock and takes a supplier for the latest keys. - Startup flags that disagree with remote come back as deferred activations. - Cancellation is rethrown instead of cached as UNREADABLE. - isEnabled returns false for IMMEDIATE flags instead of throwing. - Header check covers the startup flag types. Co-authored-by: Cursor <cursoragent@cursor.com>
|
All seven addressed in 05c808f.
|


Description
One line summary
Share startup flag snapshot and latching across hosts, so Android and iOS read APP_STARTUP flags the same way.
Details
StartupFlagsLatchreads a host-providedIStartupFlagsStoreonce per process and mirrors the APP_STARTUP subset of remote flags back to it, writing only when that subset changed. An unreadable store is rewritten rather than skipped.StartupFlagsreports where values came from (STORE,MISSING,UNREADABLE,NO_STORE). Everything exceptSTOREmeans off.isEnabledreturns false for non APP_STARTUP flags.FeatureManager.refreshAtStartuplatches APP_STARTUP flags from that snapshot (even after an earlierrefresh) and IMMEDIATE flags from remote keys. Startup flags that disagree with remote come back asDeferredFeatureActivation. Later refreshes never latch APP_STARTUP flags from remote keys.UNREADABLE, so every reader in a process gets the same answer.Testing
StartupFlagsTest(17 tests), including a remote turn-off reaching the next process, an unreadable store being rewritten, and cancellation not being cached../gradlew :kmp:testDebugUnitTest :kmp:iosSimulatorArm64Test spotlessCheck :kmp:verifyOneSignalKMPXCFrameworkpasses.