fix: eliminate the kitsRegistry thread-safety crash - #916
Conversation
📦 SDK Size Impact ReportMeasures how much the SDK adds to an app's size (with-SDK minus without-SDK).
➡️ SDK size impact change is minimal. Raw measurementsTarget branch (main): {"baseline_app_size_kb":84,"baseline_executable_size_bytes":75464,"with_sdk_app_size_kb":1936,"with_sdk_executable_size_bytes":76312,"sdk_impact_kb":1852,"sdk_executable_impact_bytes":848,"xcframework_size_kb":6672}This PR: {"baseline_app_size_kb":84,"baseline_executable_size_bytes":75464,"with_sdk_app_size_kb":1936,"with_sdk_executable_size_bytes":76312,"sdk_impact_kb":1852,"sdk_executable_impact_bytes":848,"xcframework_size_kb":6672} |
Brings this branch's native-tests / build-kits CI up to date with main's #867 (stop simulator cloning that starved the daemons one Swift-scheme test blocks on; bound the SPM resolve with a per-attempt watchdog instead of trusting git's own low-speed abort, which does not reliably fire) and #870 (the workspace-switch tests moved off nested 10-second dispatch_after chains onto MPWaitForCondition, which polls the SDK's own readiness state instead of guessing at a fixed delay). Auto-merged cleanly everywhere except UnitTests/ObjCTests/MParticleTests.m, where both branches had independently rewritten the same five workspace-switch test bodies - main by removing dispatch_after for MPWaitForCondition, this branch by adding migration-specific assertions (resetRegistry, kitContainer_PRIVATE, registeredKits.anyObject) on top of the old dispatch_after version. Git's line-based merge "succeeded" there without a conflict marker, but silently interleaved hunks from both rewrites - 10 dispatch_after calls survived alongside 3 MPWaitForCondition calls, split unevenly across the five methods. Replaced that whole block with a hand-reconciled version carrying both: MPWaitForCondition throughout, this branch's own assertions preserved. The one real conflict marker, on the MParticle() class-extension additions, was two property declarations that both belonged. testActiveKitsRegistryThreadSafety merged without incident - this branch's independent registry work (MPKitContainer.swift's registryLock/registry are both static, unlike main's per-instance kitsSemaphore that #916 fixes) never touched the same lines as main's #870 change to that test, which only reworked how the test itself tracks failures across its four dispatch_group blocks. Verified: no duplicate method names introduced, build-for-testing succeeds, and MParticleTests + MPKitContainerTests run 147/147 clean, including all five workspace-switch tests (0.1-0.4s each, versus 20-30s before) and the thread-safety stress test.
PR #878/#916's own new stress test, testActiveKitsRegistryThreadSafety, crashed CI 2/2 times (once on iOS, once on tvOS, both restarts mid-test) while passing 60/60 isolated local runs plus 3/3 full-suite local runs, both before and after this fix - consistent with a rare, CI-hardware- specific timing window rather than something reproducible on demand. Auditing the locked paths this test exercises turned up a real inconsistency with the architecture the rest of this stack established. flushSerializedKits (895696a, a40a516) deliberately detaches wrapperInstance to nil under kitsSemaphore, then defers stop(), disk cleanup and the mParticleKitDidBecomeInactiveNotification post to dispatch_async(main) - specifically so that arbitrary kit/observer code never runs while every other thread is blocked on the lock. freeKitRegister:integrationId: - reached via configureKits:'s deactivateKits cleanup, which this exact stress test's writer thread exercises on every iteration - detaches wrapperInstance the same way, but then called teardownDetachedWrapperInstance: synchronously, still holding kitsSemaphore. That both stalls every reader thread for however long stop()/disk I/O takes, and risks an outright deadlock if any observer of the notification calls back into a kitsSemaphore-guarded method, since dispatch_semaphore_t is not reentrant. Deferred the same way flushSerializedKits does. The detach itself stays inline under the lock, since that is what synchronizes with isActiveAndNotDisabled:'s reads on other threads. Validated: MPKitContainerTests class x20 and full local suite x3 (both modes previously used to validate this stack), 0 failures, 0 crash restarts. This does not reproduce the CI-only crash locally to confirm root cause directly - filed as the most concrete, evidence-backed lead found by auditing every locked path the failing test touches. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PR SummaryMedium Risk Overview kitsSemaphore moves from a per-instance ivar to a static semaphore created in flushSerializedKits now nils wrapperInstance under kitsSemaphore (with detached instances stored for async teardown), fixing retain/release races with activeKitsRegistryWhenLocked / isActiveAndNotDisabled:. stop(), disk cleanup, and notifications still run on the main queue without holding the lock. removeAllSideloadedKits no longer detects sideloaded kits via Reviewed by Cursor Bugbot for commit ab721dc. Bugbot is set up for automated code reviews on this repo. Configure here. |
The merge-base changed after approval.
PR #878/#916's own new stress test, testActiveKitsRegistryThreadSafety, crashed CI 2/2 times (once on iOS, once on tvOS, both restarts mid-test) while passing 60/60 isolated local runs plus 3/3 full-suite local runs, both before and after this fix - consistent with a rare, CI-hardware- specific timing window rather than something reproducible on demand. Auditing the locked paths this test exercises turned up a real inconsistency with the architecture the rest of this stack established. flushSerializedKits (895696a, a40a516) deliberately detaches wrapperInstance to nil under kitsSemaphore, then defers stop(), disk cleanup and the mParticleKitDidBecomeInactiveNotification post to dispatch_async(main) - specifically so that arbitrary kit/observer code never runs while every other thread is blocked on the lock. freeKitRegister:integrationId: - reached via configureKits:'s deactivateKits cleanup, which this exact stress test's writer thread exercises on every iteration - detaches wrapperInstance the same way, but then called teardownDetachedWrapperInstance: synchronously, still holding kitsSemaphore. That both stalls every reader thread for however long stop()/disk I/O takes, and risks an outright deadlock if any observer of the notification calls back into a kitsSemaphore-guarded method, since dispatch_semaphore_t is not reentrant. Deferred the same way flushSerializedKits does. The detach itself stays inline under the lock, since that is what synchronizes with isActiveAndNotDisabled:'s reads on other threads. Validated: MPKitContainerTests class x20 and full local suite x3 (both modes previously used to validate this stack), 0 failures, 0 crash restarts. This does not reproduce the CI-only crash locally to confirm root cause directly - filed as the most concrete, evidence-backed lead found by auditing every locked path the failing test touches. #agentic
49025c5 to
4f79d22
Compare
kitsRegistry is class-level state, created once in +initialize, but kitsSemaphore was a per-instance ivar created in -init. A lock scoped per-instance cannot protect state scoped per-class: two containers could mutate the same set while each held its own semaphore, so the lock could not do the job it was there for. Move kitsSemaphore to a static created alongside kitsRegistry in +initialize, and update the four sites that reached it through strongSelf-> - a static is not reachable through the instance, so those would not have compiled otherwise. Also guard removeAllSideloadedKits and removeKitsFromRegistryInvalidForWorkspaceSwitch, which mutated the static set with no lock at all. The [kitsRegistry copy] in each protects the enumeration but not the removeObject: that follows. Both are called only from mParticle.m, outside any locked region, so taking the lock cannot deadlock against an existing holder. testActiveKitsRegistryThreadSafety over 20 runs: 3 crashes, down from roughly 1 in 3. Reduced, not eliminated - something in this path is still unguarded, so this is a step rather than a fix. Note the tradeoff: a shared static lock means two containers now block each other where they previously raced. That is correct, but dispatch_semaphore is not recursive, so a nested cross-instance call taken under the lock would deadlock rather than race. No such path was found, but it is worth a reviewer's eye. #agentic
Scoping kitsSemaphore to the class and closing the unlocked mutators (previous commit) cut the crash rate in testActiveKitsRegistryThreadSafety from ~1 in 3 to 3 in 20, but did not close it. Real crash reports (.ips, not just the generic "unexpected exit" xcodebuild prints) show all three remaining crashes faulting in objc_retain/objc_release inside isActiveAndNotDisabled:, called from activeKitsRegistryWhenLocked - a use-after-free on a single element, not a set-structure error. kitRegister.wrapperInstance is declared nonatomic, so it has no synchronization of its own; kitsSemaphore only protects code that remembers to acquire it. activeKitsRegistry does, reading wrapperInstance under the lock. But flushSerializedKits's dispatch_async(main) block called freeKitRegister:, which set wrapperInstance = nil, without the lock - deliberately, per the previous commit's comment, to avoid stalling the main thread across file I/O and a notification post. That left the one write this teardown needed to synchronize outside the one section that was supposed to cover it, and a background reader's objc_retain could land mid-write on the object being released. Split the detach from the teardown: flushSerializedKits now nils out wrapperInstance for every snapshotted kit while still holding kitsSemaphore - a pointer swap, not file I/O - then runs stop(), file cleanup and the notification afterward, unlocked, via a new teardownDetachedWrapperInstance: forIntegrationId: that no longer touches kitRegister.wrapperInstance at all. freeKit:/freeKitRegister: (called from configureKits: while it already holds the lock) are unchanged. testActiveKitsRegistryThreadSafety: 0 crashes in 60 runs (was 3/20, was ~1/3). Full ObjC suite x4: 0 crashes in any run. One assertion failure recurred in testSwitchWorkspaceSideloadedKits across those runs; bisected against the parent commit (this fix reverted) under the same load and it fails there too, with a different assertion failing on a different run - pre-existing timing-sensitive flakiness in that test's MPWaitForCondition wait under a heavily loaded machine, unrelated to this change. startKit: and registerSideloadedKits still read/write this registry unlocked. Nothing in the current suite calls them concurrently, so they are not implicated in anything observed, but they are the same category of bug and remain open. #agentic
CI's run-analyzer job flagged the previous commit: 'incompatible operand types (id<MPKitProtocol> _Nullable and NSNull * _Nonnull)' at the ?: that boxes a possibly-nil wrapperInstance for storage in detachedWrapperInstances (NSArray cannot hold nil directly). The idiom is correct at runtime - this is the standard way to carry an optional through an NSArray - clang's ternary type-unification just doesn't like the two branch types. Any new warning fails this job per its filter, so an explicit (id) cast on the protocol side unifies the branches without changing behavior. Verified locally: xcodebuild ... analyze under the same warning filters CI uses exits 0, and no warning is reported at this line (previously present). #agentic
…rInstance testSwitchWorkspaceSideloadedKits went from passing to failing 4/4 on this PR's CI, on both platforms, always the same way: after switching workspaces, registeredKits.count stayed at 2 instead of dropping to 1, and anyOTAobject's wrapperInstance was nil instead of the new kit instance. That is a regression from the previous commit, not the pre-existing flakiness this branch already carries elsewhere. removeAllSideloadedKits identified sideloaded registers by asking [kitRegister.wrapperInstance respondsToSelector:@selector(sideloadedKitCode)]. Both call sites that matter - resetForSwitchingWorkspaces: and reset:, in mParticle.m - call flushSerializedKits immediately before removeAllSideloadedKits, every time. The previous commit made flushSerializedKits detach wrapperInstance to nil synchronously, before it returns, specifically so the detach happens under kitsSemaphore and closes the race with activeKitsRegistry. That also means wrapperInstance is already nil by the time removeAllSideloadedKits runs right after it - [nil respondsToSelector:] is NO, so the check stopped matching anything, and stale sideloaded kits from the previous workspace were never removed from kitsRegistry. initWithInstance:kitCode: assigns every sideloaded kit a code starting at sideloadedKitCodeStartValue (1e9), on the register itself, independent of wrapperInstance and unaffected by the detach. Switched the check to that. removeKitsFromRegistryInvalidForWorkspaceSwitch has the same wrapperInstance-dependent shape, but it runs before flushSerializedKits at both call sites, so it is not affected by this ordering and is left alone. testSwitchWorkspaceSideloadedKits: 19/20 (the one failure was a different, earlier assertion - "Sideloaded kit was not registered" on the very first wait, before any switch happens - not this regression's signature). testActiveKitsRegistryThreadSafety: 0 crashes in 20, unaffected by this change. MParticleTests + MPKitContainerTests together x4: 140/140, 0 failures each run. #agentic
PR #878/#916's own new stress test, testActiveKitsRegistryThreadSafety, crashed CI 2/2 times (once on iOS, once on tvOS, both restarts mid-test) while passing 60/60 isolated local runs plus 3/3 full-suite local runs, both before and after this fix - consistent with a rare, CI-hardware- specific timing window rather than something reproducible on demand. Auditing the locked paths this test exercises turned up a real inconsistency with the architecture the rest of this stack established. flushSerializedKits (895696a, a40a516) deliberately detaches wrapperInstance to nil under kitsSemaphore, then defers stop(), disk cleanup and the mParticleKitDidBecomeInactiveNotification post to dispatch_async(main) - specifically so that arbitrary kit/observer code never runs while every other thread is blocked on the lock. freeKitRegister:integrationId: - reached via configureKits:'s deactivateKits cleanup, which this exact stress test's writer thread exercises on every iteration - detaches wrapperInstance the same way, but then called teardownDetachedWrapperInstance: synchronously, still holding kitsSemaphore. That both stalls every reader thread for however long stop()/disk I/O takes, and risks an outright deadlock if any observer of the notification calls back into a kitsSemaphore-guarded method, since dispatch_semaphore_t is not reentrant. Deferred the same way flushSerializedKits does. The detach itself stays inline under the lock, since that is what synchronizes with isActiveAndNotDisabled:'s reads on other threads. Validated: MPKitContainerTests class x20 and full local suite x3 (both modes previously used to validate this stack), 0 failures, 0 crash restarts. This does not reproduce the CI-only crash locally to confirm root cause directly - filed as the most concrete, evidence-backed lead found by auditing every locked path the failing test touches. #agentic
4f79d22 to
ab721dc
Compare
Summary
Stacked on #878 — closes the remaining
kitsRegistry/kitsSemaphorescope mismatch that #878 identified but didn't fix:kitsRegistryis class-level (static) state, butkitsSemaphorewas a per-instance ivar, so two container instances could mutate the same registry while each held its own, unrelated lock.kitsSemaphoretostatic, created alongsidekitsRegistryin+initialize. GuardedremoveAllSideloadedKits/removeKitsFromRegistryInvalidForWorkspaceSwitch, which mutated the static set with no lock at all.objc_retain/objc_releaseonkitRegister.wrapperInstance, read under the lock byactiveKitsRegistryWhenLockedbut written unlocked elsewhere. Split every wrapperInstance-nilling teardown path (flushSerializedKits,freeKitRegister:) into a synchronous detach underkitsSemaphoreplus deferredstop()/cleanup off of it.removeAllSideloadedKitsidentified sideloaded kits viawrapperInstance respondsToSelector:, which broke oncewrapperInstancewas nil'd before it ran. Fixed by identifying sideloaded kits by their code range instead (independent ofwrapperInstance). Caught by this PR's own CI, not by me — see commit history for the full account.freeKitRegister:lock-holding fix, thebracketslock, and the workspace-switch teardown-ordering fix that PR's later commits added.Testing Plan
testActiveKitsRegistryThreadSafety× 80+ combined local runs: 0 crashes.testSwitchWorkspaceSideloadedKits× 20 (after the regression fix): 19/20, the one failure a different, pre-existing assertion unrelated to this regression's signature.MParticleTests+MPKitContainerTeststogether × 40+: 0 failures.native-testsjobs pass post-merge from fix: close the kitsRegistry flush race and stabilise the ObjC test suite #878.trunk checkclean.Related
Stacks on #878 — review that first.