Skip to content

fix: close the kitsRegistry flush race and stabilise the ObjC test suite - #878

Merged
thomson-t merged 5 commits into
mainfrom
fix/kit-registry-lock-and-rokt-test-stability
Sep 4, 2026
Merged

thomson-t merged 5 commits into
mainfrom
fix/kit-registry-lock-and-rokt-test-stability

Conversation

@nickolas-dimitrakas

@nickolas-dimitrakas nickolas-dimitrakas commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Summary

MPKitContainer had three unsynchronized races around kit teardown and configuration, plus flaky tests hiding them:

  • kitsRegistry flush raceflushSerializedKits mutated the registry with no lock, reached via the early return in configureKits: before that lock is taken.
  • brackets had no lock at allbracketForKit: read it and updateBracketsWithConfiguration: mutated it concurrently with zero synchronization, not even the kit-registry semaphore. Added a dedicated bracketsSemaphore.
  • freeKitRegister: ran arbitrary code (stop(), disk I/O, a notification post) while holding kitsSemaphore, stalling every other thread or risking deadlock. Deferred it off the lock, matching how flushSerializedKits already worked — then added a dispatch_group so 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 deferred stop() tear it back down).

Also stabilized several flaky/racy tests along the way: fixed-delay dispatch_after workspace-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-thread XCTFail).

The brackets race 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

  • Local: MPKitContainerTests + MParticleTests classes, 100+ combined runs across the fixes in this PR, 0 crashes, 0 failures. Full ObjC suite ×6+, 0 crashes.
  • CI (Xcode 16.4 / iOS 18, the environment that originally caught this): all 4 native-tests jobs (iOS/tvOS × ObjC/Swift) now pass, including testActiveKitsRegistryThreadSafety and testBracketForKitThreadSafety, which failed 2/2 runs before this PR's later commits.
  • trunk check clean.

Related

Stacked on #870/#867 (CI hang and test-stability fixes, already merged). #916 stacks on top of this with the remaining kitsRegistry/kitsSemaphore scope mismatch fix.

@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown

📦 SDK Size Impact Report

Measures how much the SDK adds to an app's size (with-SDK minus without-SDK).

Metric Target Branch This PR Change
App Bundle Impact 1.81 MB 1.81 MB +N/A
Executable Impact 848 bytes 848 bytes +N/A
XCFramework Size 6.51 MB 6.52 MB +8 KB

➡️ SDK size impact change is minimal.

Raw measurements

Target 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}

Base automatically changed from test/stabilize-flaky-concurrency-tests to main August 31, 2026 18:09
@nickolas-dimitrakas nickolas-dimitrakas self-assigned this Sep 1, 2026
@nickolas-dimitrakas
nickolas-dimitrakas force-pushed the fix/kit-registry-lock-and-rokt-test-stability branch from 3b51b5b to 35fe1c8 Compare September 1, 2026 20:24
@nickolas-dimitrakas
nickolas-dimitrakas marked this pull request as ready for review September 1, 2026 20:29
@nickolas-dimitrakas
nickolas-dimitrakas requested a review from a team as a code owner September 1, 2026 20:29
@cursor

cursor Bot commented Sep 1, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes threading and kit lifecycle ordering during workspace switch/reset in core SDK infrastructure; fixes races but alters when new kits can start relative to old kit stop().

Overview
Hardens MPKitContainer concurrency and kit teardown ordering so workspace switch/reset no longer races the kit registry, bracket map, or deferred stop() work.

Kit flush now snapshots kitsRegistry under kitsSemaphore instead of iterating it unlocked. Bracket reads/writes use a new bracketsSemaphore (separate from the kit lock to avoid deadlock with configureKits:). freeKitRegister: detaches the wrapper under the kit lock but moves stop(), disk cleanup, and inactive notifications to the main queue via teardownDetachedWrapperInstance:. A static kitTeardownGroup plus notifyWhenKitTeardownComplete:block: lets resetForSwitchingWorkspaces: and reset: wait until scheduled teardown finishes before clearing the shared instance and starting the next workspace.

Test-only changes: workspace-switch tests drop fixed dispatch_after delays for MPWaitForCondition; MPRoktTests gets longer async timeouts, SDK message-queue draining before OCMock teardown, and safer concurrent failure reporting in the bracket stress test.

Reviewed by Cursor Bugbot for commit 03aa576. Bugbot is set up for automated code reviews on this repo. Configure here.

@nickolas-dimitrakas
nickolas-dimitrakas marked this pull request as draft September 1, 2026 21:38
nickolas-dimitrakas added a commit that referenced this pull request Sep 2, 2026
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>
nickolas-dimitrakas added a commit that referenced this pull request Sep 2, 2026
…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>
@nickolas-dimitrakas
nickolas-dimitrakas marked this pull request as ready for review September 2, 2026 21:05
Comment thread mParticle-Apple-SDK/Kits/MPKitContainer.m Outdated

@cursor cursor Bot 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.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread mParticle-Apple-SDK/Kits/MPKitContainer.m
nickolas-dimitrakas and others added 5 commits September 4, 2026 12:02
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>
@thomson-t
thomson-t force-pushed the fix/kit-registry-lock-and-rokt-test-stability branch from bea2959 to 03aa576 Compare September 4, 2026 16:02
thomson-t pushed a commit that referenced this pull request Sep 4, 2026
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
@thomson-t
thomson-t merged commit 718d59d into main Sep 4, 2026
77 checks passed
@thomson-t
thomson-t deleted the fix/kit-registry-lock-and-rokt-test-stability branch September 4, 2026 16:16
thomson-t pushed a commit that referenced this pull request Sep 4, 2026
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
thomson-t pushed a commit that referenced this pull request Sep 4, 2026
* 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
@cursor cursor Bot mentioned this pull request Sep 4, 2026
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.

2 participants