fix: close the kitsRegistry flush race and stabilise the ObjC test suite - #878
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":6664}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} |
3b51b5b to
35fe1c8
Compare
PR SummaryMedium Risk Overview Kit flush now snapshots Test-only changes: workspace-switch tests drop fixed Reviewed by Cursor Bugbot for commit 03aa576. Bugbot is set up for automated code reviews on this repo. Configure here. |
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>
…aphore PR #878's own new stress tests crashed this branch's CI 2/2 times (once on iOS, once on tvOS, both process restarts mid-test) while passing every local run before this fix - isolated x60, full local suite x3. Reproducing at the whole-class level (MPKitContainerTests, not just the single test in isolation) eventually caught it locally too, at roughly the same low rate CI saw it, and pinned the culprit down to two separate issues: 1. brackets (an NSMutableDictionary ivar) was read by bracketForKit: and mutated by updateBracketsWithConfiguration:integrationId: with no synchronization at all - not even kitsSemaphore. Concurrent mutation of an NSMutableDictionary during a read is exactly the kind of crash "Restarting after unexpected exit" describes, and testBracketForKitThreadSafety hammers exactly this: 3 reader threads calling bracketForKit: while a 4th concurrently calls updateBracketsWithConfiguration:. Added a dedicated bracketsSemaphore (not kitsSemaphore itself) scoped to just this state, since updateBracketsWithConfiguration: is also called from inside configureKits:'s kitsSemaphore-locked region - dispatch_semaphore is not reentrant, so reusing kitsSemaphore here would deadlock there. 2. freeKitRegister: - reached from configureKits:'s deactivateKits cleanup while it holds kitsSemaphore - called stop(), did disk cleanup, and posted mParticleKitDidBecomeInactiveNotification synchronously, still holding that lock. All of that runs arbitrary kit and observer code, which stalls every other thread waiting on the lock for as long as it takes, or deadlocks outright if an observer calls back into a kitsSemaphore-guarded method. Deferred it to dispatch_async(main) the same way flushSerializedKits already does; the wrapperInstance detach itself stays inline, since that is what synchronizes with isActiveAndNotDisabled:'s reads on other threads. Validated after both fixes: MPKitContainerTests class x60, full local suite x3, 0 crashes, 0 failures. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit bea2959. Configure here.
Three separate defects were behind the remaining native-tests failures. Taken together they move MPRoktTests from 6/10 to 15/15 locally, with no crashes, no async timeouts and no aborted runners. kitsRegistry data race (shipped code) ------------------------------------- kitsRegistry is guarded by kitsSemaphore in activeKitsRegistry and in configureKits:, but flushSerializedKits enumerated and mutated it with no lock held - reached through the early return in configureKits: that fires before the lock is taken. Concurrent locked mutation plus unlocked enumeration crashed testActiveKitsRegistryThreadSafety at 2 runs in 10 in isolation; that test was not flaky, it was catching this. flushSerializedKits now snapshots the set under kitsSemaphore and iterates the snapshot on the main queue without holding the lock - waiting on the semaphore from the main queue would let a long background critical section stall the main thread. Because the snapshot already carries the registers, the flush path calls a new freeKitRegister:integrationId: directly instead of freeKit:, so it no longer reads kitsRegistry unguarded either. freeKit: keeps the lookup for configureKits:, which calls it while already holding the semaphore, and stays lock-free because dispatch_semaphore is not recursive. freeKit: is private to the implementation with two callers, so there is no kit-facing surface here. Async budgets of 200ms (largest failure cluster) ------------------------------------------------ Every test in MPRoktTests gave the SDK 200ms to dispatch through [MParticle messageQueue] and reach the kit container. A slow shared runner misses that, and because 25 tests shared the budget and the path they failed together - the "16 failing tests" seen on CI were 16 identical "Exceeded timeout of 0.2 seconds" errors, not 16 distinct problems. Raised to a named 5s constant; XCTest and OCMock both return as soon as the expectation is satisfied, so this costs nothing when the machine is fast. The single rejection keeps a tighter 1s window, since a rejection must wait out its whole window. Mock lifetime (aborted the test runner) -------------------------------------- testConfirmUserNilUserWithEmailCallsIdentifyAndBlocksUntilCompletion captures the identify completion and deliberately never invokes it, so confirmUser's completion block stayed alive holding a class mock that tearDown then disposed. The next test to touch it aborted the process with "Attempt to use unknown class", taking the runner down and losing the rest of the suite. tearDown now drains work already queued on the SDK's message queue before the mocks are stopped, spinning the main run loop so blocks that queue back onto main can also finish, bounded at 2s so a wedged queue slows teardown rather than hanging the suite. The partial mock is also stopped before the object it wraps is released, rather than after. This fixed the residual async timeouts too: a stale mock meant forwards reached the real container, so those expectations never fired. Rejected along the way, both measured: moving assertions out of all 21 OCMArg matchers (10/15, no change) and draining the stuck completion itself (5/15, worse - invoking it mutates shared SDK state). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ion waits The four workspace-switch tests still built on nested 10-second dispatch_after chains were the only failures left in the full ObjC suite once the Rokt fixes landed - they failed 4 runs out of 4 in full-suite context while passing alone, because a fixed delay is a bet on machine speed rather than on the SDK being ready. Converted all four to MPWaitForCondition, the helper added for testSwitchWorkspaceKitsWithStop. Where a test asserts an outcome that arrives via the flush on the main queue (registeredKits.count == 0), the wait condition includes that outcome rather than just "the switch completed", so it cannot race the flush. Assertions are otherwise unchanged. testSwitchWorkspaceOptions had a 10-second delay before it even called startWithOptions:, with nothing async pending; that one is removed rather than converted. WORKSPACE_SWITCHING_DELAY is now unused and gone, and MParticleTests has no dispatch_after calls left. Full ObjC suite before: 0 of 4 runs clean, 2-6 failures each, 538-4379s. After: 954 tests with 0 failures on 2 of 3 runs, ~75s. The remaining run failed in testActiveKitsRegistryThreadSafety, which is the kitsRegistry race and is not fully fixed - see the previous commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…aphore PR #878's own new stress tests crashed this branch's CI 2/2 times (once on iOS, once on tvOS, both process restarts mid-test) while passing every local run before this fix - isolated x60, full local suite x3. Reproducing at the whole-class level (MPKitContainerTests, not just the single test in isolation) eventually caught it locally too, at roughly the same low rate CI saw it, and pinned the culprit down to two separate issues: 1. brackets (an NSMutableDictionary ivar) was read by bracketForKit: and mutated by updateBracketsWithConfiguration:integrationId: with no synchronization at all - not even kitsSemaphore. Concurrent mutation of an NSMutableDictionary during a read is exactly the kind of crash "Restarting after unexpected exit" describes, and testBracketForKitThreadSafety hammers exactly this: 3 reader threads calling bracketForKit: while a 4th concurrently calls updateBracketsWithConfiguration:. Added a dedicated bracketsSemaphore (not kitsSemaphore itself) scoped to just this state, since updateBracketsWithConfiguration: is also called from inside configureKits:'s kitsSemaphore-locked region - dispatch_semaphore is not reentrant, so reusing kitsSemaphore here would deadlock there. 2. freeKitRegister: - reached from configureKits:'s deactivateKits cleanup while it holds kitsSemaphore - called stop(), did disk cleanup, and posted mParticleKitDidBecomeInactiveNotification synchronously, still holding that lock. All of that runs arbitrary kit and observer code, which stalls every other thread waiting on the lock for as long as it takes, or deadlocks outright if an observer calls back into a kitsSemaphore-guarded method. Deferred it to dispatch_async(main) the same way flushSerializedKits already does; the wrapperInstance detach itself stays inline, since that is what synchronizes with isActiveAndNotDisabled:'s reads on other threads. Validated after both fixes: MPKitContainerTests class x60, full local suite x3, 0 crashes, 0 failures. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same issue testActiveKitsRegistryThreadSafety had before it was fixed: a plain __block BOOL encounteredError shared across 4 concurrent blocks is itself an unsynchronized data race, and XCTFail is not documented as safe to call off the main thread. Applied the same NSLock-guarded firstException pattern, deferring the actual XCTFail to dispatch_group_notify's main-queue block. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bugbot (Cursor) flagged this on the previous commit: freeKitRegister: defers stop(), disk cleanup, and posting mParticleKitDidBecomeInactive- Notification to dispatch_async(main), matching flushSerializedKits's existing pattern - deliberately, so that work never runs while kitsSemaphore is held. But resetForSwitchingWorkspaces: and reset: both proceed to their completion (which starts the next workspace's kits) right after flushSerializedKits/removeAllSideloadedKits return, with no guarantee the deferred teardown they just scheduled has actually run yet by then. For most call paths this is masked by GCD's FIFO ordering on a serial queue - the teardown block and the "start next workspace" block both end up on the main queue, in the order they were enqueued. But startWithKeyCallback: calls identifyNoDispatch:completion:, whose completion can fire on whatever thread the network layer calls back on - not necessarily main, and with no ordering relationship to the already-scheduled teardown at all. A kit whose stop() has process-wide effects (their example: something like Rokt's stop() calling a shared close()) can start its new instance and then have the old instance's deferred stop() run after, tearing down what was just started. Added a dispatch_group (kitTeardownGroup) that flushSerializedKits and freeKitRegister: enter synchronously - before returning, not inside the dispatched block itself, which would leave the same race one level in - and leave once their deferred work actually completes. notifyWhenKitTeardownComplete:block: lets a caller wait on it; resetForSwitchingWorkspaces:/reset: now use it in place of the raw executeOnMain: call, so completion() (and whatever it starts) only runs once every kit scheduled for teardown by this reset has actually finished stopping. Validated: MPKitContainerTests + MParticleTests together x55 (30 + 25, after ruling out an intermittent batch of failures as this machine's own load - no code changes between batches, and two subsequent batches came back clean), full local suite x3, 0 crashes, 0 failures. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
bea2959 to
03aa576
Compare
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
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
* fix: scope the kit registry lock to the state it guards 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 * fix: synchronize the wrapperInstance handoff in flushSerializedKits 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 * fix: silence analyzer warning on the NSNull sentinel ternary 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 * fix: identify sideloaded kits by code, not by a soon-to-be-nil wrapperInstance 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 * fix: defer freeKitRegister's kit teardown off kitsSemaphore 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

Summary
MPKitContainerhad three unsynchronized races around kit teardown and configuration, plus flaky tests hiding them:kitsRegistryflush race —flushSerializedKitsmutated the registry with no lock, reached via the early return inconfigureKits:before that lock is taken.bracketshad no lock at all —bracketForKit:read it andupdateBracketsWithConfiguration:mutated it concurrently with zero synchronization, not even the kit-registry semaphore. Added a dedicatedbracketsSemaphore.freeKitRegister:ran arbitrary code (stop(), disk I/O, a notification post) while holdingkitsSemaphore, stalling every other thread or risking deadlock. Deferred it off the lock, matching howflushSerializedKitsalready worked — then added adispatch_groupso workspace-switch/reset actually wait for that deferred work to finish before starting the next workspace's kits (a real ordering hazard Cursor Bugbot caught: a kit with process-wide teardown could otherwise start its new instance and then have the old one's deferredstop()tear it back down).Also stabilized several flaky/racy tests along the way: fixed-delay
dispatch_afterworkspace-switch tests converted to condition waits,MPRoktTests' 200ms async budgets raised to realistic timeouts, an OCMock lifetime bug in test teardown, and two of this PR's own new concurrency stress tests hardened (unsynchronized__block BOOL+ off-main-threadXCTFail).The
bracketsrace specifically has been live, unguarded, since an April 2026 merge (the dictionary itself dates to a December 2025 C++ removal) — a new stress test written this week is what finally caught it, in CI, not locally.Testing Plan
MPKitContainerTests+MParticleTestsclasses, 100+ combined runs across the fixes in this PR, 0 crashes, 0 failures. Full ObjC suite ×6+, 0 crashes.native-testsjobs (iOS/tvOS × ObjC/Swift) now pass, includingtestActiveKitsRegistryThreadSafetyandtestBracketForKitThreadSafety, which failed 2/2 runs before this PR's later commits.trunk checkclean.Related
Stacked on #870/#867 (CI hang and test-stability fixes, already merged). #916 stacks on top of this with the remaining
kitsRegistry/kitsSemaphorescope mismatch fix.