Skip to content

fix(analytics): guard polling start so concurrent callers don't leak intervals (SDK-81) - #278

Merged
tylerjroach merged 4 commits into
masterfrom
fix/sdk-85-thread-safe-polling-start
Jul 22, 2026
Merged

fix(analytics): guard polling start so concurrent callers don't leak intervals (SDK-81)#278
tylerjroach merged 4 commits into
masterfrom
fix/sdk-85-thread-safe-polling-start

Conversation

@tylerjroach

@tylerjroach tylerjroach commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Summary

startPollingForDefinitions had a check-then-act race: await this._initialFetchPromise happens before the !this.pollingInterval check, giving the event loop an interleaving point. Two concurrent callers both await the fetch, both observe pollingInterval == null, and both call setInterval — the first interval handle is overwritten and orphaned (still scheduled to poll, no reference to clear it).

Cache the in-flight start as _startPromise and short-circuit subsequent calls to share that promise. stopPollingForDefinitions and shutdown clear the cache so a later start can re-establish polling cleanly.

Context

Linear: SDK-81. Same cross-SDK push as the Java and Ruby fixes — three SDKs were affected (Node, Java, Ruby). Python uses a not self._polling_task check that's safe under the GIL; Go uses CompareAndSwap.

Test plan

  • Full local_flags.js suite (45 examples) passes
  • New "is idempotent under concurrent startPollingForDefinitions calls and does not leak intervals" test fires 8 concurrent startPollingForDefinitions() calls and asserts setInterval was called exactly once. Before this fix the count would be 8.

🤖 Generated with Claude Code

@tylerjroach
tylerjroach requested review from a team and jakewski June 29, 2026 18:11
@linear-code

linear-code Bot commented Jun 29, 2026

Copy link
Copy Markdown

SDK-85

SDK-81

@tylerjroach tylerjroach changed the title fix(flags): guard polling start so concurrent callers don't leak intervals (SDK-85) fix(analytics): guard polling start so concurrent callers don't leak intervals (SDK-85) Jun 29, 2026
@tylerjroach tylerjroach changed the title fix(analytics): guard polling start so concurrent callers don't leak intervals (SDK-85) fix(analytics): guard polling start so concurrent callers don't leak intervals (SDK-81) Jun 29, 2026
@tylerjroach

Copy link
Copy Markdown
Contributor Author

@greptileai

@tylerjroach

Copy link
Copy Markdown
Contributor Author

@greptileai review

Copilot AI 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.

Pull request overview

This PR makes LocalFeatureFlagsProvider.startPollingForDefinitions() safe to call concurrently by caching the in-flight start operation, preventing redundant initial fetches and ensuring polling setup is only initiated once per lifecycle.

Changes:

  • Add _startPromise to coalesce concurrent startPollingForDefinitions() calls into a single async start/fetch.
  • Reset _startPromise on stopPollingForDefinitions() and shutdown() (and on initial fetch failure) to allow clean restarts/retries.
  • Add Vitest coverage for concurrent starts, restart-after-stop behavior, and retry-after-failure behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
packages/mixpanel/lib/flags/local_flags.js Introduces _startPromise caching and lifecycle resets to dedupe concurrent starts and allow restart/retry.
packages/mixpanel/test/flags/local_flags.js Adds tests validating start coalescing under concurrency and correct cache clearing on stop/failure.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/mixpanel/lib/flags/local_flags.js
Comment thread packages/mixpanel/lib/flags/local_flags.js
Comment thread packages/mixpanel/test/flags/local_flags.js Outdated

@ketanmixpanel ketanmixpanel 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.

Check copilot comments.

tylerjroach and others added 3 commits July 20, 2026 19:12
…rvals

startPollingForDefinitions had a check-then-act race: the initial
_fetchFlagDefinitions() was awaited *before* the `!this.pollingInterval`
check, giving the event loop an interleaving point. Two concurrent
callers both await the fetch, both observe pollingInterval == null,
and both call setInterval — the first interval handle is overwritten
and leaks (still scheduled to poll, no way to clear it).

Cache the in-flight start as `_startPromise` and short-circuit
subsequent calls to share the same promise. stopPollingForDefinitions
and shutdown clear the cache so a later start can re-establish polling.

Linear: SDK-85

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The initial startPollingForDefinitions guard had two dead-lock states
where the cached _startPromise permanently blocked further starts:

1. When enable_polling=false, stop hits the else branch (warn log)
   without clearing _startPromise. Subsequent start becomes a no-op.
   Now unconditionally clear, matching shutdown().

2. When the initial fetch throws, the outer try/catch logs and returns,
   leaving _startPromise resolved-with-undefined. Callers cannot retry
   via start() — they'd have to stop first, which logs a spurious warning.
   Now clear _startPromise in the catch so the next start attempts a
   fresh fetch.

Also reworked the concurrent-start test to assert on
_fetchFlagDefinitions call count rather than setInterval count. The
!pollingInterval check-then-set is already atomic within a microtask
in JS's single-threaded model, so setInterval-count would be 1 with
or without the guard. What the guard actually prevents is N redundant
fetches under N concurrent starts.
Two doc/behavior polish items surfaced by Copilot on the SDK-81 changes:

- _startPromise comment described the wrong failure mode. JS's
  single-threaded model makes the !this.pollingInterval check +
  setInterval assignment atomic within a microtask, so setInterval
  itself was never the race. The guard prevents N concurrent starts
  each firing their own _fetchFlagDefinitions() and clobbering
  _initialFetchPromise. Comment now reflects that.

- stopPollingForDefinitions warned "polling was not active" whenever
  pollingInterval was null, including the legitimate enable_polling=false
  lifecycle (start → stop) and the mid-start case (stop before setInterval
  fires). Only warn when neither an active interval nor an in-flight
  _startPromise existed — i.e. the caller truly stopped something that
  was never started.
@tylerjroach
tylerjroach force-pushed the fix/sdk-85-thread-safe-polling-start branch from 8747a6f to ec98138 Compare July 20, 2026 23:16
@tylerjroach

Copy link
Copy Markdown
Contributor Author

Rebased onto master (pulled in service account support) and addressed the three Copilot review threads. Summary:

Copilot #1_startPromise comment accuracy (local_flags.js:58)
Rewrote the comment. JS's single-threaded model makes the !this.pollingInterval check + setInterval assignment atomic within a microtask, so setInterval itself was never the race. What the guard actually prevents is N concurrent starts each awaiting their own _fetchFlagDefinitions() and clobbering _initialFetchPromise — N redundant HTTP calls where one would do. Comment now reflects that.

Copilot #2 — noisy "polling was not active" warning (local_flags.js:110)
The warn fired whenever pollingInterval was null, including the legitimate enable_polling=false lifecycle (start → stop) and the mid-start case (stop before setInterval fires). Guard is now else if (!this._startPromise) — only warn when neither an active interval nor an in-flight start existed.

Copilot #3 — real setInterval spy leaks live timer (test/flags/local_flags.js:855)
Swapped vi.spyOn(global, "setInterval") for vi.spyOn(global, "setInterval").mockImplementation(() => 999) — the spy still records calls (so toHaveBeenCalledTimes(1) works) but no real timer is scheduled, so a failed assertion can't leak a live interval into the test process.

Tests: 48/48 green locally (npm test -- flags/local_flags). CI will re-run against the rebased commits.

Comment thread packages/mixpanel/lib/flags/local_flags.js
Comment thread packages/mixpanel/lib/flags/local_flags.js
Per Ketan's review on #278: when a scheduled poll of
_fetchFlagDefinitions fails, clear the interval and _startPromise
by delegating to stopPollingForDefinitions(). Continuing to poll a
failing endpoint just spammed the log without recovering — if the
failure is transient (network blip), a follow-up
startPollingForDefinitions() gets us back; if it's permanent
(revoked token, project deleted), silent retries hide the real
problem instead of letting the caller notice.

Note: this is a deliberate divergence from the Ruby/Python/Java/Go
providers, which log-and-continue. If we want cross-SDK parity in
the future, revisit and align.

New test "stops polling and resets state when a refresh fails" pins
the behavior — mocks setInterval to capture the callback, fires it
after seeding a 500, and asserts clearInterval was called with the
handle, pollingInterval/_startPromise are cleared, and the error log
mentions "stopping polling".
@tylerjroach

Copy link
Copy Markdown
Contributor Author

Pushed `a91549c` addressing your two threads.

Both threads (local_flags.js:93): the polling setInterval callback now delegates to stopPollingForDefinitions() in its catch, which clears both pollingInterval and _startPromise. On any refresh failure we log the error (now with "stopping polling" in the message) and stop; the caller can restart via startPollingForDefinitions().

New test stops polling and resets state when a refresh fails mocks setInterval to capture the callback, fires it after seeding a 500 response, and asserts:

  • clearInterval was called with the interval handle
  • pollingInterval is null
  • _startPromise is null
  • error log message mentions "stopping polling"

Heads up — this is a deliberate divergence from the Ruby / Python / Java / Go providers, which log-and-continue on polling errors. Since your rationale (surface real failures, don't spam) applies equally to the other SDKs, worth opening a follow-up to align them if we want cross-SDK parity. Happy to file that.

All 49 flag tests pass.

Regarding the earlier three Copilot threads that were still open — those were addressed in ec98138 before this push (comment accuracy, else-if suppression of the "not active" warning, and mocking setInterval in the concurrent-start test). Resolving them alongside these.

@tylerjroach
tylerjroach merged commit bf7c751 into master Jul 22, 2026
6 checks passed
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.

3 participants