@@ -129,6 +129,13 @@ type envContextImpl struct {
129129 // GetEvaluator / addCredential continue to run during the (potentially seconds-long) SDK client
130130 // construction when re-anchoring to a new key.
131131 reconcileMu sync.Mutex
132+
133+ // anchorClientGen counts how many times the upstream anchor client has been (re)established. A
134+ // re-anchor commit bumps it. startSDKClient builds its client without c.mu, so a slow build can
135+ // finish after a later re-anchor already installed a fresh anchor client; it captures this value at
136+ // launch and, on completion, discards its (now stale) build if the generation has advanced rather
137+ // than clobbering the current anchor client. Guarded by c.mu.
138+ anchorClientGen uint64
132139}
133140
134141// Implementation of the DataStoreQueries interface that the streams package uses as an abstraction of
@@ -406,7 +413,9 @@ func NewEnvContext(
406413 }
407414
408415 // Connecting may take time, so do this in parallel
409- go envContext .startSDKClient (envConfig .SDKKey , readyCh , allConfig .Main .IgnoreConnectionErrors )
416+ // launchGen is 0 here: no re-anchor can have committed yet (the env isn't wired into reconcile until
417+ // after construction returns), so this initial build is never superseded and its result is recorded.
418+ go envContext .startSDKClient (envConfig .SDKKey , readyCh , allConfig .Main .IgnoreConnectionErrors , 0 )
410419
411420 cleanupInterval := params .ExpiredCredentialCleanupInterval
412421 if cleanupInterval == 0 { // 0 means it wasn't specified; the config system disallows 0 as a valid value.
@@ -455,7 +464,7 @@ func (c *envContextImpl) addCredential(newCredential credential.SDKCredential) {
455464 case config.SDKKey :
456465 if key == c .keyRotator .AnchorKey () {
457466 if ! c .offline {
458- go c .startSDKClient (key , nil , false )
467+ go c .startSDKClient (key , nil , false , c . anchorClientGen )
459468 }
460469 if c .metricsEventPub != nil { // metrics event publisher always uses SDK key
461470 c .metricsEventPub .ReplaceCredential (key )
@@ -497,43 +506,52 @@ func (c *envContextImpl) removeCredential(oldCredential credential.SDKCredential
497506 }
498507}
499508
500- func (c * envContextImpl ) startSDKClient (sdkKey config.SDKKey , readyCh chan <- EnvContext , suppressErrors bool ) {
509+ func (c * envContextImpl ) startSDKClient (sdkKey config.SDKKey , readyCh chan <- EnvContext , suppressErrors bool , launchGen uint64 ) {
501510 client , err := c .sdkClientFactory (sdkKey , c .sdkConfig , c .sdkInitTimeout )
502511 c .mu .Lock ()
503512 name := c .identifiers .GetDisplayName ()
513+ // The build happens before we take c.mu. By now the env may be closed, the key may have been
514+ // revoked, or a re-anchor may have committed a fresh anchor client since this build was launched
515+ // (anchorClientGen advanced). In any of those cases this build is stale: close it rather than install
516+ // it, so it cannot clobber the current anchor client. This must not rely on client==nil: a failed SDK
517+ // build returns a non-nil, uninitialized client together with the error, so a stale failed build
518+ // would otherwise replace a healthy anchor client with a dead one. Only defined keys are
519+ // revocation-checked: an undefined SDK key is never tracked, and dropping its client would break envs
520+ // that legitimately run without an SDK key (offline / not-yet-configured / tests).
521+ superseded := c .anchorClientGen != launchGen
504522 droppedInactive := false
505- if client != nil && (c .closed || (sdkKey .Defined () && ! c .sdkKeyIsActive (sdkKey ))) {
506- // startSDKClient builds the client before taking c.mu, so by the time we hold the lock the key
507- // may already have been revoked (rotated away) or the environment may have been closed. In
508- // either case the freshly-built client must be closed here rather than installed, otherwise its
509- // upstream connection and goroutines leak until env.Close() (and, once closed, nothing ever
510- // closes it). removeCredential cannot close it because the client was never in c.clients.
511- //
512- // The revocation check applies only to a defined key: an undefined (empty) SDK key is never a
513- // tracked credential -- the rotator filters undefined credentials out of its accepted set -- so
514- // it can never be "revoked", and dropping its client would break environments that legitimately
515- // run without an SDK key (e.g. offline or not-yet-configured envs, and test fixtures).
523+ if client != nil && (c .closed || superseded || (sdkKey .Defined () && ! c .sdkKeyIsActive (sdkKey ))) {
516524 _ = client .Close ()
517525 client = nil
518526 droppedInactive = true
519527 }
520528 if client != nil {
521- // If a client already exists for this SDK key (e.g. the key was re-anchored back into the
522- // primary slot while a previous client for it was still alive in its grace period), close
523- // the stale one before replacing it so its upstream connection and goroutines are not leaked.
529+ // If a client already exists for this key (e.g. it was re-anchored back into the anchor slot
530+ // while a prior client for it was still in its grace period), close the stale one before
531+ // replacing it so its upstream connection and goroutines are not leaked.
524532 if existing := c .clients [sdkKey ]; existing != nil && existing != client {
525533 _ = existing .Close ()
526534 }
527535 c .clients [sdkKey ] = client
528-
529- // The data store instance is created by the SDK when it creates the client. Now that we have a
530- // data store, we can finish setting up the Evaluator for this environment.
531- c .rebuildEvaluator ()
536+ c .rebuildEvaluator () // the SDK created the data store during Build; wire the evaluator to it now
537+ }
538+ // Record this build's result as the env's init status only when it is the current anchor's build: not
539+ // superseded by a newer anchor client, and its key is still the anchor. A genuine failure of the
540+ // current anchor is thus recorded (the middleware 401s a broken env); a stale build's late failure is
541+ // not, so it cannot 401 a healthy re-anchored env.
542+ if ! superseded && sdkKey == c .keyRotator .AnchorKey () {
543+ c .initErr = err
532544 }
533- c .initErr = err
534545 c .mu .Unlock ()
535546
536547 switch {
548+ case droppedInactive :
549+ // The build finished but was superseded by a re-anchor, or its key was revoked, or the env
550+ // closed, so it was discarded above rather than installed (even if it also errored -- a
551+ // discarded build's error is moot). The environment is still consistent: no stale client left
552+ // behind.
553+ c .globalLoggers .Infof ("SDK key %s build was superseded, revoked, or the environment was closed " +
554+ "before it finished initializing; the client was discarded" , sdkKey .Masked ())
537555 case err != nil :
538556 if suppressErrors {
539557 c .globalLoggers .Warnf ("Ignoring error initializing LaunchDarkly client for %q: %+v" ,
@@ -546,12 +564,6 @@ func (c *envContextImpl) startSDKClient(sdkKey config.SDKKey, readyCh chan<- Env
546564 }
547565 return
548566 }
549- case droppedInactive :
550- // The client initialized successfully but the key was revoked (or the environment was closed)
551- // before it could be installed, so it was discarded above. The environment is still considered
552- // ready: it is in a consistent state with no client for this no-longer-tracked key.
553- c .globalLoggers .Infof ("SDK key %s was revoked or the environment was closed before its client " +
554- "finished initializing; the client was discarded" , sdkKey .Masked ())
555567 default :
556568 c .globalLoggers .Infof ("Initialized LaunchDarkly client for %q (SDK key %s)" , name , sdkKey .Masked ())
557569 }
@@ -560,10 +572,9 @@ func (c *envContextImpl) startSDKClient(sdkKey config.SDKKey, readyCh chan<- Env
560572 }
561573}
562574
563- // sdkKeyIsActive reports whether the given SDK key is still a tracked credential -- either the primary
564- // key or one within its deprecation grace period -- according to the rotator. startSDKClient uses this
565- // to avoid installing (and thereby leaking) a client for a key that was revoked while the client was
566- // being constructed.
575+ // sdkKeyIsActive reports whether the given SDK key is still a tracked credential -- the anchor or a key
576+ // within its deprecation grace period -- according to the rotator. startSDKClient uses this to avoid
577+ // installing (and thereby leaking) a client for a key that was revoked while it was being built.
567578func (c * envContextImpl ) sdkKeyIsActive (sdkKey config.SDKKey ) bool {
568579 return slices .Contains (c .keyRotator .AllCredentials (), credential .SDKCredential (sdkKey ))
569580}
@@ -590,27 +601,18 @@ func (c *envContextImpl) ReconcileCredentials(newSet credential.AcceptedSet) {
590601 c .reconcileCredentials (newSet , time .Now ())
591602}
592603
593- // reconcileCredentials is the time-injectable implementation of ReconcileCredentials. now is the
594- // reference time for expiry math; production callers pass time.Now() via ReconcileCredentials.
595- //
596- // Order of operations: add → re-anchor → remove. Additions drain first so credential mappings
597- // are registered before any synchronous re-anchor runs, the re-anchor swaps the upstream client
598- // while the old anchor is still serving, and expirations drain last so the old anchor's client
599- // (and any other revoked keys) are torn down only after the new anchor is fully operational. addCredential
600- // opens an upstream client only for the anchor — non-anchor server keys are accepted and routed
601- // without a second connection.
604+ // reconcileCredentials is the time-injectable implementation of ReconcileCredentials (now is the
605+ // reference time for expiry math).
602606//
603- // Re-anchor handling: Reconcile defers the SDK anchor flip and strips the new anchor from additions
604- // so this method owns the new anchor's setup. The synchronous re-anchor sequence — build a new client
605- // (new anchor key) or reuse an existing one (previously-accepted anchor key), CommitAnchor,
606- // ReplaceCredential on event dispatcher + metrics publisher, re-wire big-segment sync — happens between the addition
607- // and expiration phases. The MobilePrimaryRepoint case (primary mobile key changed to a key that
608- // was already in the accepted set) is handled in the same window: addCredential's gate won't fire
609- // for it because the key isn't in additions, so ReplaceCredential is called synchronously.
607+ // Order: add -> re-anchor -> remove. Adding first registers the new keys' mappings; the re-anchor then
608+ // swaps the upstream client while the old anchor is still serving; removing last tears down the old
609+ // anchor (and any revoked keys) only once the new one is up. addCredential opens an upstream client
610+ // only for the anchor -- non-anchor server keys are routed without a second connection.
610611//
611- // reconcileMu serializes concurrent reconciles. If two reconciles arrive while a synchronous build
612- // is in flight, the second blocks here until the first completes — matching the all-or-nothing
613- // atomicity requirement.
612+ // reconcileMu serializes this whole method against concurrent reconciles and the cleanup ticker (see
613+ // triggerCredentialChanges). See reanchor for the SDK-anchor swap; MobilePrimaryRepoint is handled
614+ // inline below (a primary-mobile change to an already-accepted key isn't in additions, so addCredential
615+ // won't repoint event forwarding for it).
614616func (c * envContextImpl ) reconcileCredentials (newSet credential.AcceptedSet , now time.Time ) {
615617 c .reconcileMu .Lock ()
616618 defer c .reconcileMu .Unlock ()
@@ -624,14 +626,10 @@ func (c *envContextImpl) reconcileCredentials(newSet credential.AcceptedSet, now
624626
625627 if result .AnchorChange != nil {
626628 if committed := c .reanchor (result .AnchorChange ); ! committed {
627- // The re-anchor rolled back — the new anchor's client never came up. Back out just this
628- // anchor change (other changes in the payload stand). The env-side undo mirrors
629- // RevertAnchorChange's accepted-set logic exactly:
630- // - A brand-new anchor: reanchor registered its mappings this cycle, so undo them here;
631- // RevertAnchorChange drops it from the accepted set.
632- // - A previously-accepted anchor (e.g. a non-anchor key promoted to anchor): reanchor did
633- // NOT register mappings (they predate this reconcile), so we must NOT tear them down;
634- // RevertAnchorChange keeps it accepted. It reverts to the non-anchor key it already was.
629+ // Rolled back: the new anchor's client never came up. Undo just this anchor change (other
630+ // changes in the payload stand), mirroring RevertAnchorChange. A brand-new anchor had its
631+ // mappings registered this cycle, so tear them down here; a previously-accepted anchor keeps
632+ // its mappings and reverts to the non-anchor key it already was.
635633 if ! result .AnchorChange .NewAnchorPreviouslyAccepted {
636634 c .removeCredential (result .AnchorChange .NewAnchor )
637635 }
@@ -687,18 +685,12 @@ func (c *envContextImpl) reanchor(change *credential.AnchorChange) bool {
687685 c .mu .Lock ()
688686 defer c .mu .Unlock ()
689687
690- // The two signals below answer two different questions, so they are used independently:
691- // - NewAnchorPreviouslyAccepted: were this key's credential mappings already registered? If it
692- // was already accepted (e.g. a non-anchor server key that addCredential registered mappings for
693- // but never built a client), the mappings exist, so skip re-registering. If it is brand new
694- // (Reconcile stripped it from additions, so addCredential never ran for it), register them here.
695- // - the client check below: is there already a client for this key? If so, reuse it; otherwise
696- // build one.
697- // These genuinely differ: promoting a previously-accepted non-anchor key to anchor has mappings but
698- // no client (register: skip, client: build). The one-way invariant "a client exists =>
699- // NewAnchorPreviouslyAccepted" holds (removeCredential deletes a key's client in lockstep with the
700- // rotator dropping it), which is why gating mapping registration on NewAnchorPreviouslyAccepted is
701- // safe: a key with a live client is always already accepted, so it never re-registers.
688+ // Two independent questions:
689+ // - NewAnchorPreviouslyAccepted: are this key's credential mappings already registered? A brand-new
690+ // anchor was stripped from additions, so register them now; an already-accepted key already has them.
691+ // - the client check below: does a client already exist? If so reuse it, else build one.
692+ // They differ for a previously-accepted non-anchor key promoted to anchor: mappings exist, client
693+ // does not. (A live client always implies the key was already accepted, so registration never double-fires.)
702694 if ! change .NewAnchorPreviouslyAccepted {
703695 c .registerCredentialMappings (newAnchor )
704696 }
@@ -789,6 +781,9 @@ func (c *envContextImpl) commitReanchor(newAnchor, previousAnchor config.SDKKey,
789781 }
790782
791783 c .keyRotator .CommitAnchor (newAnchor )
784+ // A new anchor client is now authoritative, so any startSDKClient build still in flight from before
785+ // this commit is stale: bump the generation so it discards itself instead of clobbering this client.
786+ c .anchorClientGen ++
792787 // The anchor now points at a healthy client (freshly built and Initialized, or a reused live
793788 // client), so clear any init error a prior client left behind — otherwise GetInitError() and the
794789 // request middleware would keep reporting a still-serving env as failed.
0 commit comments