Description
Description
We were chasing a cold-launch regression with Instruments and kept landing on FIRSessionsDependencies.addDependency in the pre-main() samples. After digging in, the call itself is fine — the problem is where it runs and what it drags in the first time it's touched.
+[FIRCrashlytics load] calls [FIRSessionsDependencies addDependencyWithName:] from a +load, so it fires during objc image init, before main(). That first call is also the first touch of the _dependencies static, which is a generic UnfairLock<Set<SessionsSubscriberName>>. To instantiate that the Swift runtime has to build the generic metadata and prove SessionsSubscriberName: Hashable (because of Set). On a cold cache — and pre-main is as cold as it gets — that first conformance lookup walks the app's __swift5_proto records. In a big app that table is large, so the scan isn't free, and it's sitting right on the launch critical path.
So it's not the lock and not the insert. It's swift_conformsToProtocol* resolving the very first conformance query in the process, kicked off by a generic type that only gets built because something called into Sessions from +load.
The code involved
FirebaseCrashlytics/Crashlytics/Crashlytics/FIRCrashlytics.m:
+ (void)load {
[FIRApp registerInternalLibrary:(Class<FIRLibrary>)self withName:@"firebase-crashlytics"];
[FIRSessionsDependencies addDependencyWithName:FIRSessionsSubscriberNameCrashlytics]; // runs pre-main
}
FirebaseSessions/Sources/Public/SessionsDependencies.swift:
public class SessionsDependencies: NSObject {
private static let _dependencies =
UnfairLock<Set<SessionsSubscriberName>>(Set()) // generic static, built on first touch
@objc public static func addDependency(name: SessionsSubscriberName) {
_dependencies.withLock { dependencies in
dependencies.insert(name)
}
}
}
Calling addDependency from +load is what forces that lazy _dependencies init to happen at image-init time instead of later.
Why +load specifically
This one surprised us a bit, so worth spelling out: +load runs unconditionally at startup for static linkage too, not just dynamic. If you're linking Crashlytics statically there's no lazy-load escape hatch — the +load always runs pre-main. And it's independent of when the app calls FirebaseApp.configure(), since +load fires long before that. So configuring Firebase late in didFinishLaunchingWithOptions: doesn't move this off the launch path.
What we'd suggest
The high-impact fix is to register the Crashlytics dependency somewhere other than +load — e.g. during component creation / configure() instead of image init. As far as we can tell the dependency set only needs to be populated before Sessions actually reads it, which still happens well after component setup, so moving it out of +load shouldn't change behavior but would take it off the pre-main path entirely.
If you wanted to also kill the generic-instantiation part, backing the set with a non-generic storage type would avoid the runtime having to reconstruct UnfairLock<Set<SessionsSubscriberName>> on first touch. But honestly the +load move is the one that matters — that's what gets it off launch.
Happy to test a patch against our setup if that helps.
One note to avoid confusion
This isn't the same thing as #15842 (the RemoteSettings/SettingsCache deadlock fix). That one doesn't touch SessionsDependencies and doesn't help here — we checked, the code on this path is unchanged through 12.10.0. Figured I'd call it out since it's the obvious "is this already fixed?" question.
Also worth saying: this scales with the host app's conformance count, so smaller apps probably never notice it. It only got loud for us because the table is big.
Reproducing the issue
Repro
Nothing exotic — link Crashlytics into an app that has a large Swift conformance table (lots of statically linked modules), profile a cold launch in Instruments (App Launch / Time Profiler), and look at the pre-main() region. You'll see something like:
start
→ dyld4 runAllInitializersForMain → runInitializersBottomUp → load_images
→ objc notifyObjCInit
→ +[FIRCrashlytics load]
→ +[FIRSessionsDependencies addDependencyWithName:]
→ one-time initialization function for SessionsDependencies._dependencies
→ __swift_instantiateConcreteTypeFromMangledName // build UnfairLock<Set<SessionsSubscriberName>>
→ swift_getTypeByMangledName
→ swift_checkGenericRequirements // Set<Element> requires Element: Hashable
→ swift_conformsToProtocolMaybeInstantiateSuperclasses // this is where the time goes
The swift_conformsToProtocol* frame dominates the subtree once the cache is cold.
Firebase SDK Version
12.8.0
Xcode Version
26.0.1
Installation Method
CocoaPods
Firebase Product(s)
Crashlytics
Targeted Platforms
iOS
Relevant Log Output
If using Swift Package Manager, the project's Package.resolved
Expand Package.resolved snippet
Replace this line with the contents of your Package.resolved.
If using CocoaPods, the project's Podfile.lock
Expand Podfile.lock snippet
Replace this line with the contents of your Podfile.lock!
Description
Description
We were chasing a cold-launch regression with Instruments and kept landing on
FIRSessionsDependencies.addDependencyin the pre-main()samples. After digging in, the call itself is fine — the problem is where it runs and what it drags in the first time it's touched.+[FIRCrashlytics load]calls[FIRSessionsDependencies addDependencyWithName:]from a+load, so it fires during objc image init, beforemain(). That first call is also the first touch of the_dependenciesstatic, which is a genericUnfairLock<Set<SessionsSubscriberName>>. To instantiate that the Swift runtime has to build the generic metadata and proveSessionsSubscriberName: Hashable(because ofSet). On a cold cache — and pre-main is as cold as it gets — that first conformance lookup walks the app's__swift5_protorecords. In a big app that table is large, so the scan isn't free, and it's sitting right on the launch critical path.So it's not the lock and not the insert. It's
swift_conformsToProtocol*resolving the very first conformance query in the process, kicked off by a generic type that only gets built because something called into Sessions from+load.The code involved
FirebaseCrashlytics/Crashlytics/Crashlytics/FIRCrashlytics.m:FirebaseSessions/Sources/Public/SessionsDependencies.swift:Calling
addDependencyfrom+loadis what forces that lazy_dependenciesinit to happen at image-init time instead of later.Why
+loadspecificallyThis one surprised us a bit, so worth spelling out:
+loadruns unconditionally at startup for static linkage too, not just dynamic. If you're linking Crashlytics statically there's no lazy-load escape hatch — the+loadalways runs pre-main. And it's independent of when the app callsFirebaseApp.configure(), since+loadfires long before that. So configuring Firebase late indidFinishLaunchingWithOptions:doesn't move this off the launch path.What we'd suggest
The high-impact fix is to register the Crashlytics dependency somewhere other than
+load— e.g. during component creation /configure()instead of image init. As far as we can tell the dependency set only needs to be populated before Sessions actually reads it, which still happens well after component setup, so moving it out of+loadshouldn't change behavior but would take it off the pre-main path entirely.If you wanted to also kill the generic-instantiation part, backing the set with a non-generic storage type would avoid the runtime having to reconstruct
UnfairLock<Set<SessionsSubscriberName>>on first touch. But honestly the+loadmove is the one that matters — that's what gets it off launch.Happy to test a patch against our setup if that helps.
One note to avoid confusion
This isn't the same thing as #15842 (the
RemoteSettings/SettingsCachedeadlock fix). That one doesn't touchSessionsDependenciesand doesn't help here — we checked, the code on this path is unchanged through 12.10.0. Figured I'd call it out since it's the obvious "is this already fixed?" question.Also worth saying: this scales with the host app's conformance count, so smaller apps probably never notice it. It only got loud for us because the table is big.
Reproducing the issue
Repro
Nothing exotic — link Crashlytics into an app that has a large Swift conformance table (lots of statically linked modules), profile a cold launch in Instruments (App Launch / Time Profiler), and look at the pre-
main()region. You'll see something like:The
swift_conformsToProtocol*frame dominates the subtree once the cache is cold.Firebase SDK Version
12.8.0
Xcode Version
26.0.1
Installation Method
CocoaPods
Firebase Product(s)
Crashlytics
Targeted Platforms
iOS
Relevant Log Output
If using Swift Package Manager, the project's Package.resolved
Expand
Package.resolvedsnippetReplace this line with the contents of your Package.resolved.If using CocoaPods, the project's Podfile.lock
Expand
Podfile.locksnippetReplace this line with the contents of your Podfile.lock!