Skip to content

fix: serialize lastUsedIdentity to avoid EXC_BAD_ACCESS under concurrent async callers - #107

Open
zerotomas wants to merge 1 commit into
Flagsmith:mainfrom
zerotomas:tomas/serialize-last-used-identity
Open

fix: serialize lastUsedIdentity to avoid EXC_BAD_ACCESS under concurrent async callers#107
zerotomas wants to merge 1 commit into
Flagsmith:mainfrom
zerotomas:tomas/serialize-last-used-identity

Conversation

@zerotomas

Copy link
Copy Markdown

Description

Flagsmith.getFeatureFlags(forIdentity:...) writes lastUsedIdentity = identity on its first line, before any dispatch:

https://github.com/Flagsmith/flagsmith-ios-client/blob/bb8564b/FlagsmithClient/Classes/Flagsmith.swift#L131

That write runs on whatever executor the caller was on. For the async overloads in Flagsmith+Concurrency.swift, that's whichever cooperative-pool thread the awaited continuation happens to resume on — the async bridge (withCheckedThrowingContinuation { ... hasFeatureFlag(...) { ... } }) doesn't hop to any specific queue before calling into the completion-based API.

When two Task { await Flagsmith.shared.hasFeatureFlag(...) } fire on different cooperative threads, both hit the unsynchronized write concurrently. Under concurrent writes to a String?, the refcounted heap storage can be stomped, and the next reader dereferences a corrupted strong reference — EXC_BAD_ACCESS at a small integer-looking address, inside the SDK frame.

We observed this in production: EXC_BAD_ACCESS (code=1, address=0x59…) inside Flagsmith.hasFeatureFlag(withID:), on a com.apple.root.user-initiated-qos.cooperative task, called from app code that fires several flag reads back-to-back on user-initiated actions.

Fix

Back lastUsedIdentity with a serialized accessor through the existing apiManager.propertiesSerialAccessQueue, matching the pattern used by _defaultFlags and _cacheConfig immediately below in the same file (added in #45), and by every ivar on APIManager itself.

  • No API change.
  • No behavior change for single-threaded callers.
  • Same queue, same access pattern — just extended to the one ivar that carries a real cross-thread race under async usage.

Scope

I deliberately kept this PR focused on lastUsedIdentity because that's the ivar tied to the observed crash. Three other ivars on FlagsmithlastUpdatedAt, lastFlags, and anyFlagStreamContinuation — are also declared without synchronization. In practice, both SSEManager and APIManager route their completions through OperationQueue.main / DispatchQueue.main.async, so the SDK-internal write paths for those three land on Main. They're theoretically racy (e.g. flagStream materialization on a background executor writing anyFlagStreamContinuation while a Main-dispatched update reads it), but I didn't observe crashes tied to them and I didn't want to broaden a bugfix PR into a defense-in-depth cleanup you haven't asked for.

Happy to extend the same treatment to those three in this PR or a follow-up, whichever you prefer.

Testing

  • swift test — all 79 tests pass, 8 skipped (unchanged from main).
  • Verified compile-clean.
  • No new SwiftLint findings vs. main (the file has pre-existing warnings unrelated to this diff).

I did not add a test that deterministically reproduces the crash. Race reproduction is timing-sensitive, and given #47 already had to tone down APIManagerTests.testConcurrentRequests for CI stability, I don't want to introduce a flaky test. If you'd like a targeted concurrency test in the shape of testConcurrentRequests (i.e. best-effort, not deterministic), I'm happy to add one — let me know.

Type of Change

  • 🛠️ Bug fix (non-breaking change which fixes an issue)

…ent async callers

`getFeatureFlags(forIdentity:...)` writes `lastUsedIdentity = identity` on
its first line, before dispatching the actual request. That write runs on
whatever executor the caller was on — for the async overloads in
`Flagsmith+Concurrency.swift`, that's whichever cooperative-pool thread
the awaited continuation happens to resume on.

When two `Task { await Flagsmith.shared.hasFeatureFlag(...) }` fire on
different cooperative threads, both hit the unsynchronized write. Under
concurrent writes, the refcounted heap storage of the `String?` can be
stomped, and the next reader dereferences a corrupted strong reference —
`EXC_BAD_ACCESS` with a small integer-looking address, inside the SDK
frame.

Back `lastUsedIdentity` with a serialized accessor through the existing
`apiManager.propertiesSerialAccessQueue`, matching the pattern used by
`_defaultFlags` and `_cacheConfig` immediately below in the same file
(and by every ivar on `APIManager`). No API change; no behavior change
for single-threaded callers.
@zerotomas
zerotomas requested a review from a team as a code owner September 10, 2026 02:26
@zerotomas
zerotomas requested review from talissoncosta and removed request for a team September 10, 2026 02:26
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.

1 participant